Compare commits

..

17 Commits

Author SHA1 Message Date
dd999b9355 refacto: refacto repo layer code while adding new usecase methods
All checks were successful
Build and test / Build (push) Successful in 2m19s
2024-10-26 17:27:33 +02:00
14ee642aab refacto: add db tx as a possible input for repo methods
All checks were successful
Build and test / Build (push) Successful in 2m22s
2024-10-25 23:52:43 +02:00
b30a5c5c2d feat: implement repo expense methods
All checks were successful
Build and test / Build (push) Successful in 2m28s
2024-10-24 23:39:13 +02:00
58cff774e6 feat: add expense repo type conversion 2024-10-24 22:33:24 +02:00
716a58d44c fix: use simple slices instead of []*T 2024-10-24 22:32:57 +02:00
de7c6f7223 test: add event repo tests 2024-10-23 23:29:44 +02:00
0258ff6620 web: add test login and signup page
All checks were successful
Build and test / Build (push) Successful in 2m28s
2024-10-23 22:39:19 +02:00
0da8b29507 test: try to test user repo and event repo
All checks were successful
Build and test / Build (push) Successful in 2m27s
2024-10-23 09:54:00 +02:00
304651e7ff feat: implement event repo methods 2024-10-20 23:27:17 +02:00
74ae6b7877 doc: add readme 2024-10-20 23:26:45 +02:00
b4259e9a51 doc: add licence for event.go 2024-10-20 21:27:26 +02:00
46c14b63ea feat: add a method for event. And introduce the mock
All checks were successful
Build and test / Build (push) Successful in 2m27s
2024-10-20 21:26:12 +02:00
c27dfc687f minor: cleanup 2024-10-19 17:14:08 +02:00
3d616bff50 db: finish sql commands
All checks were successful
Build and test / Build (push) Successful in 2m23s
2024-10-19 17:08:05 +02:00
dac36db284 db: add more tables 2024-10-19 13:28:02 +02:00
80a5f1f8a8 fix: CHANGE NAMES AGAIN. Just want to be clearer
All checks were successful
Build and test / Build (push) Successful in 2m20s
2024-10-18 23:24:31 +02:00
a55fd26f90 repo: add some more sql for events 2024-10-18 21:41:53 +02:00
51 changed files with 2039 additions and 262 deletions

View File

@ -46,7 +46,7 @@ format: # format code.
.PHONY: add-copyright
add-copyright: # add license to file headers.
@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc,web
@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc,web,mock
.PHONY: swagger
swagger: # Run swagger.

108
README.md
View File

@ -24,6 +24,8 @@
- [2024/10/15](#20241015)
- [2024/10/16](#20241016)
- [2024/10/17](#20241017)
- [2024/10/18](#20241018)
- [2024/10/19](#20241019)
<!--toc:end-->
A tricount like expense-sharing system written in Go
@ -421,23 +423,29 @@ The following basic use cases are to be implemented at the first time.
- [X] A user signs up
- [X] A user logs in
- [X] A user lists their events (pagination)
- [ ] A user lists their events (pagination)
- [ ] A user sees the detail of an event (description, members, amount)
- [ ] A user sees the expenses of an event (total amount, personal expenses, pagination)
- [ ] A user sees the detail of an expense: (time, amount, payers, recipients)
- [ ] A user adds an expense
- [ ] A user updates/changes an expense (may handle some extra access control)
- [] A user deletes an expense (may handle some extra access control)
- [] A user restore a deleted expense
- [] A user can pay the debt to other members
- [X] A user creates an event (and participate to it)
- [ ] A user can pay the debt to other members (just a special case of expense)
- [ ] A user creates an event (and participate to it)
- [ ] A user updates the event info
- [ ] A user invites another user by sending a mail with a token.
- [ ] A user joins an event by accepting an invitation
- [] A user quits an event (they cannot actually, but we can make as if they quitted)
- [ ] A user cannot see other user's information
- [ ] A user cannot see the events that they didn't participated in.
For the second stage:
- [ ] A user can archive an event
- [ ] A user deletes an expense (may handle some extra access control)
- [ ] A user restore a deleted expense
- [ ] Audit log for expense updates/deletes
- [ ] ~A user quits an event (they cannot actually, but we can make as if they
quitted)~ **No we can't quit!**
With those functionalities, there will be an usable product. And then we can
work on other aspects. For example:
@ -486,3 +494,91 @@ GROUP BY
o.id, o.first_name, o.last_name;
```
### 2024/10/19
I don't plan to handle deletions at this first stage, but I note down what I
have thought of.
1. Just delete. But keep a replica at the front end of the object that we are
deleting. And propose an option to restore (so a new record is added to the DB)
2. Just delete, but wait. The request is sent to a queue with a timeout of
several seconds, if the user regrets, they can cancel the request. This can be
done on the front, but also on the back. I think it is better to do in on the
front-end.
3. Never deletes. But keep a state in the DB `deleted`. They will just be
ignored when counting.
4. Deletes when doing database cleanup. They lines deleted will be processed
when we cleanup the DB. And they will be definitely deleted at that time.
I can create a audit log table to log all the critical
changes in my `expense` table (update or delete).
Finished with the basic SQL commands. Learned a lot from SQL about `JOIN`,
aggregation and `CTE`. SQL itself has quite amount of things to learn, this
is on my future learning plan!
_I found it quite interesting that simply with SQL, we can simulate the most
business logic. It is a must-have competence for software design and
development._
### 2024/10/20
I was thinking that I should write test for `sqlc` generated code. And then
I found out `gomock` and see how it is done in the project of
`techschoo/simplebank`. It's a great tutorial project. It makes me questioning
my own project's structure. It seems overwhelmed at least at the repo level.
I don't actually use the sqlc generated object, instead I do a conversion to
my `Retrieved` objects. But with some advanced configuration we could make the
output of sqlc object directly usable. That will save a lot of code.
The problem I saw here is the dependency on `sqlc/models`, and the model
designed there has no business logic. Everything is done in the handlers
and the handlers query directly the DB.
More concretely, `sqlc` generates `RawJSON` for some fields that are embedded
structs. So I have to do the translation somewhere.
So I will just stick to the plan and keep going with the predefined structure.
I have to figure out how to use the generated mock files.
The goals for the next week is to finish the basic operations for each level
and run some integration tests with `curl`.
### 2024/10/22
I am facing come difficulties on testing of the `repo` functions.
First, I have to keep the business logic in the service layer. That means I
have to create the transaction at the service layer. I don't need to depend
on the implementation detail. So I have created a Transaction interface.
I don't care of the type of `tx` because I will pass it to repo layer and I
suppose that it knows what it is doing. Considering this, my repo `Create`
function will have to take an any and deduct the type of `tx`. So the layer
becomes untestable, because I have to pass a *sql.Tx into it and create a
querier.
Since this repo layer is just a wrapping layer between the `sqlc.models` and
my own models, I can extract the conversion part to functions and test them.
I'm not testing the whole thing but I test what I can.
### 2024/10/24
When writing the tests. I am asking myself the differences between `[]T`,
`[]*T` and `*[]T`.
`*[]T` is simple, it is a reference to the original slice. So modifying it
means modifying the original slice.
But between `[]*T` and `[]T`, the only difference that I see (pointed out by
`ChatGPT`) is how the memory is allocated. With `[]T` it might be better for
the GC to deal with the memory free. I thing for my project I will stick to
`[]T`.
### 2024/10/25
Read this [article](https://konradreiche.com/blog/two-common-go-interface-misuses/)
today, maybe I am abusing the usage of interfaces?

2
go.mod
View File

@ -18,7 +18,7 @@ require (
github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.27.0
golang.org/x/net v0.25.0
golang.org/x/net v0.26.0
)
require (

4
go.sum
View File

@ -153,8 +153,8 @@ golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -64,7 +64,7 @@ type createParams struct {
// Since we use JWT method, this token is not stored anywhere. Thus it
// stops at the controller level.
func (sc *SessionController) Create(ctx *gin.Context) {
var user model.UserExistDTO
var user model.UserExistRequest
if err := ctx.Bind(&user); err != nil {
log.ErrorLog("param error", "err", err)

View File

@ -37,12 +37,12 @@ func NewtestUserUsecase() usecase.User {
func (*testUserUsecase) Create(
ctx context.Context,
u *model.UserCreateDTO,
) (*model.UserInfoVO, error) {
u *model.UserCreateRequest,
) (*model.UserInfoResponse, error) {
return nil, nil
}
func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error {
func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistRequest) error {
switch u.Email {
case "a@b.c":
if u.Password == "strong password" {
@ -57,10 +57,10 @@ func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error
return nil
}
func (*testUserUsecase) GetUserBaseVOByID(
func (*testUserUsecase) GetUserBaseResponseByID(
ctx context.Context,
userID int,
) (*model.UserBaseVO, error) {
) (*model.UserBaseResponse, error) {
// TODO:
return nil, nil
}

View File

@ -57,14 +57,14 @@ func NewUserController(us usecase.User) User {
}
func (uc *UserController) Create(ctx core.Context) {
var userDTO model.UserCreateDTO
var userRequest model.UserCreateRequest
if err := ctx.Bind(&userDTO); err != nil {
if err := ctx.Bind(&userRequest); err != nil {
core.WriteResponse(ctx, UserParamsErr, nil)
return
}
_, err := uc.userUsecase.Create(ctx, &userDTO)
_, err := uc.userUsecase.Create(ctx, &userRequest)
if err != nil {
core.WriteResponse(ctx, err, nil)
return

View File

@ -25,7 +25,9 @@ package repo
import (
"context"
"database/sql"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
)
@ -34,6 +36,8 @@ type dbRepository struct {
db *sql.DB
}
const queryTimeout = 3 * time.Second
func NewDBRepository(db *sql.DB) repo.DBRepository {
return &dbRepository{
db: db,
@ -66,3 +70,11 @@ func (dr *dbRepository) Transaction(
data, err := txFunc(ctx, tx)
return data, err
}
func getQueries(queries *sqlc.Queries, tx any) *sqlc.Queries {
transaction, ok := tx.(*sql.Tx)
if ok {
return sqlc.New(transaction)
}
return queries
}

View File

@ -0,0 +1,276 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// 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"
"database/sql"
"encoding/json"
"errors"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"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"
)
type eventRepository struct {
queries *sqlc.Queries
}
func NewEventRepository(db *sql.DB) repo.EventRepository {
return &eventRepository{
queries: sqlc.New(db),
}
}
// Create implements repo.EventRepository.
func (e *eventRepository) Create(
ctx context.Context,
evEntity *model.EventEntity,
tx any,
) (*model.EventEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
event, err := queries.InsertEvent(timeoutCtx, sqlc.InsertEventParams{
Name: evEntity.Name,
Description: sql.NullString{String: evEntity.Description, Valid: true},
TotalAmount: sql.NullInt32{Int32: int32(evEntity.TotalAmount), Valid: true},
DefaultCurrency: evEntity.DefaultCurrency,
OwnerID: int32(evEntity.OwnerID),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err != nil {
return nil, err
}
return &model.EventEntity{
ID: int(event.ID),
Name: event.Name,
Description: event.Description.String,
TotalAmount: int(event.TotalAmount.Int32),
DefaultCurrency: event.DefaultCurrency,
OwnerID: int(event.OwnerID),
CreatedAt: event.CreatedAt,
UpdatedAt: event.UpdatedAt,
}, nil
}
func convToEventRetrieved(eventDTO *sqlc.GetEventByIDRow) (*model.EventRetrieved, error) {
// marshal owner and users
var owner model.UserBaseRetrieved
err := json.Unmarshal(eventDTO.Owner, &owner)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
var users []model.UserBaseRetrieved
err = json.Unmarshal(eventDTO.Users, &users)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
eventRetrieved := &model.EventRetrieved{
ID: int(eventDTO.ID),
Name: eventDTO.Name,
Description: eventDTO.Description.String,
TotalAmount: model.MakeMoney(
int(eventDTO.TotalAmount.Int32),
model.Currency(eventDTO.DefaultCurrency),
),
DefaultCurrency: model.Currency(eventDTO.DefaultCurrency),
CreatedAt: eventDTO.CreatedAt,
UpdatedAt: eventDTO.UpdatedAt,
Owner: &owner,
Users: users,
}
return eventRetrieved, nil
}
// GetByID implements repo.EventRepository.
func (e *eventRepository) GetByID(
ctx context.Context,
eventID int,
tx any,
) (*model.EventRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
eventDTO, err := queries.GetEventByID(timeoutCtx, int32(eventID))
if err != nil {
log.ErrorLog("query error", "err", err)
return nil, err
}
return convToEventRetrieved(&eventDTO)
}
func convToEventList(eventsDTO []sqlc.ListEventsByUserIDRow) ([]model.EventListRetrieved, error) {
events := make([]model.EventListRetrieved, len(eventsDTO))
for i, evDTO := range eventsDTO {
var owner model.UserBaseRetrieved
err := json.Unmarshal(evDTO.Owner, &owner)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
ev := model.EventListRetrieved{
ID: int(evDTO.ID),
Name: evDTO.Name,
Description: evDTO.Description.String,
Owner: &owner,
CreatedAt: evDTO.CreatedAt,
}
events[i] = ev
}
return events, nil
}
// ListEventsByUserID implements repo.EventRepository.
func (e *eventRepository) ListEventsByUserID(
ctx context.Context,
userID int,
tx any,
) ([]model.EventListRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
eventsDTO, err := queries.ListEventsByUserID(timeoutCtx, int32(userID))
if err != nil {
log.ErrorLog("query error", "err", err)
return nil, err
}
return convToEventList(eventsDTO)
}
// UpdateInfo implements repo.EventRepository.
func (e *eventRepository) UpdateEventByID(
ctx context.Context,
event *model.EventUpdateEntity,
tx any,
) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
err := queries.UpdateEventByID(timeoutCtx, sqlc.UpdateEventByIDParams{
ID: int32(event.ID),
Name: event.Name,
Description: sql.NullString{String: event.Description, Valid: true},
UpdatedAt: time.Now(),
})
return err
}
// GetParticipation implements repo.EventRepository.
func (e *eventRepository) GetParticipation(
ctx context.Context,
userID, eventID int,
tx any,
) (*model.ParticipationEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
partDTO, err := queries.GetParticipation(timeoutCtx, sqlc.GetParticipationParams{
UserID: int32(userID),
EventID: int32(eventID),
})
if errors.Is(err, sql.ErrNoRows) {
// No error, but participation not found
return nil, nil
}
if err != nil {
return nil, err
}
return &model.ParticipationEntity{
ID: int(partDTO.ID),
UserID: int(partDTO.UserID),
EventID: int(partDTO.EventID),
InvitedByUserID: int(partDTO.InvitedByUserID.Int32),
CreatedAt: partDTO.CreatedAt,
UpdatedAt: partDTO.UpdatedAt,
}, nil
}
// InsertParticipation implements repo.EventRepository.
func (e *eventRepository) InsertParticipation(
ctx context.Context,
userID int,
eventID int,
invitedByUserID int,
tx any,
) (*model.ParticipationEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
var invitedBy sql.NullInt32
if invitedByUserID == 0 {
invitedBy = sql.NullInt32{Int32: 0, Valid: false}
} else {
invitedBy = sql.NullInt32{Int32: int32(invitedByUserID), Valid: true}
}
participationDTO, err := queries.InsertParticipation(timeoutCtx, sqlc.InsertParticipationParams{
UserID: int32(userID),
EventID: int32(eventID),
InvitedByUserID: invitedBy,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err != nil {
return nil, err
}
return &model.ParticipationEntity{
ID: int(participationDTO.ID),
UserID: int(participationDTO.UserID),
EventID: int(participationDTO.EventID),
InvitedByUserID: int(participationDTO.InvitedByUserID.Int32),
CreatedAt: participationDTO.CreatedAt,
UpdatedAt: participationDTO.UpdatedAt,
}, nil
}

View File

@ -0,0 +1,122 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// 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 (
"database/sql"
"encoding/json"
"testing"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/stretchr/testify/assert"
)
func TestConvToEventRetrieved(t *testing.T) {
input := &sqlc.GetEventByIDRow{
ID: 123,
Name: "event",
Description: sql.NullString{Valid: false},
TotalAmount: sql.NullInt32{Valid: false},
DefaultCurrency: "EUR",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
Users: json.RawMessage(`[{"id":1, "first_name":"owner", "last_name":"owner"}]`),
}
want := &model.EventRetrieved{
ID: 123,
Name: "event",
Description: "",
TotalAmount: model.Money{Amount: 0, Currency: "EUR"},
DefaultCurrency: model.Currency("EUR"),
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
Users: []model.UserBaseRetrieved{
{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
}
got, err := convToEventRetrieved(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}
func TestConvToEventList(t *testing.T) {
input := []sqlc.ListEventsByUserIDRow{
{
ID: 123,
Name: "event",
Description: sql.NullString{Valid: false},
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
},
{
ID: 456,
Name: "event2",
Description: sql.NullString{String: "super event", Valid: true},
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
},
}
want := []model.EventListRetrieved{
{
ID: 123,
Name: "event",
Description: "",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
{
ID: 456,
Name: "event2",
Description: "super event",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
}
got, err := convToEventList(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}

View File

@ -0,0 +1,267 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// 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"
"database/sql"
"encoding/json"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"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"
)
type expenseRepository struct {
queries *sqlc.Queries
}
func NewExpenseRepository(db *sql.DB) repo.ExpenseRepository {
return &expenseRepository{
queries: sqlc.New(db),
}
}
// DeleteExpense implements repo.ExpenseRepository.
func (e *expenseRepository) DeleteExpense(ctx context.Context, expenseID int, tx any) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
return queries.DeleteExpense(timeoutCtx, int32(expenseID))
}
// DeleteTransactionsOfExpense implements repo.ExpenseRepository.
func (e *expenseRepository) DeleteTransactionsOfExpense(
ctx context.Context,
expenseID int,
tx any,
) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
return queries.DeleteTransactionsOfExpenseID(timeoutCtx, int32(expenseID))
}
// GetExpenseByID implements repo.ExpenseRepository.
func (e *expenseRepository) GetExpenseByID(
ctx context.Context,
expenseID int,
tx any,
) (*model.ExpenseRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.GetExpenseByID(timeoutCtx, int32(expenseID))
if err != nil {
return nil, err
}
expense, err := convToExpenseRetrieved(&expenseDTO)
if err != nil {
return nil, err
}
return expense, nil
}
func convToPayments(raw json.RawMessage) ([]model.Payment, error) {
var paymentsRetrieved []model.PaymentRetrieved
err := json.Unmarshal(raw, &paymentsRetrieved)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
payments := make([]model.Payment, len(paymentsRetrieved))
for i, p := range paymentsRetrieved {
payment := model.Payment{
PayerID: p.PayerID,
PayerFirstName: p.PayerFirstName,
PayerLastName: p.PayerLastName,
Amount: model.MakeMoney(p.Amount, model.Currency(p.Currency)),
}
payments[i] = payment
}
return payments, nil
}
func convToBenefits(raw json.RawMessage) ([]model.Benefit, error) {
var benefitsRetrieved []model.BenefitRetrieved
err := json.Unmarshal(raw, &benefitsRetrieved)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
benefits := make([]model.Benefit, len(benefitsRetrieved))
for i, b := range benefitsRetrieved {
benefit := model.Benefit{
RecipientID: b.RecipientID,
RecipientFirstName: b.RecipientFirstName,
RecipientLastName: b.RecipientLastName,
Amount: model.MakeMoney(b.Amount, model.Currency(b.Currency)),
}
benefits[i] = benefit
}
return benefits, nil
}
func convToExpenseRetrieved(expenseDTO *sqlc.GetExpenseByIDRow) (*model.ExpenseRetrieved, error) {
payments, err := convToPayments(expenseDTO.Payments)
if err != nil {
// Unexpected
return nil, err
}
benefits, err := convToBenefits(expenseDTO.Benefits)
if err != nil {
// Unexpected
return nil, err
}
expenseRetrieved := &model.ExpenseRetrieved{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.UpdatedAt,
Amount: model.MakeMoney(int(expenseDTO.Amount), model.Currency(expenseDTO.Currency)),
EventID: int(expenseDTO.EventID),
Detail: model.ExpenseDetail{
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
},
Payments: payments,
Benefits: benefits,
}
return expenseRetrieved, nil
}
// InsertExpense implements repo.ExpenseRepository.
func (e *expenseRepository) InsertExpense(
ctx context.Context,
expenseEntity *model.ExpenseEntity,
tx any,
) (*model.ExpenseEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.InsertExpense(timeoutCtx, sqlc.InsertExpenseParams{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Amount: int32(expenseEntity.Amount),
Currency: expenseEntity.Currency,
EventID: int32(expenseEntity.EventID),
Name: sql.NullString{String: expenseEntity.Name, Valid: true},
Place: sql.NullString{String: expenseEntity.Place, Valid: true},
})
if err != nil {
return nil, err
}
return &model.ExpenseEntity{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.CreatedAt,
Amount: int(expenseDTO.Amount),
Currency: expenseDTO.Currency,
EventID: int(expenseDTO.EventID),
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
}, nil
}
// ListExpensesByEventID implements repo.ExpenseRepository.
func (e *expenseRepository) ListExpensesByEventID(
ctx context.Context,
id int,
tx any,
) ([]model.ExpensesListRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
listDTO, err := queries.ListExpensesByEventID(timeoutCtx, int32(id))
if err != nil {
return nil, err
}
res := make([]model.ExpensesListRetrieved, len(listDTO))
for i, dto := range listDTO {
elem := model.ExpensesListRetrieved{
ID: int(dto.ID),
CreatedAt: dto.CreatedAt,
UpdatedAt: dto.UpdatedAt,
Amount: model.MakeMoney(int(dto.Amount), model.Currency(dto.Currency)),
EventID: int(dto.EventID),
Detail: model.ExpenseDetail{
Name: dto.Name.String,
Place: dto.Place.String,
},
}
res[i] = elem
}
return res, nil
}
// UpdateExpenseByID implements repo.ExpenseRepository.
func (e *expenseRepository) UpdateExpenseByID(
ctx context.Context,
expenseUpdate *model.ExpenseUpdateEntity,
tx any,
) (*model.ExpenseEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.UpdateExpenseByID(timeoutCtx, sqlc.UpdateExpenseByIDParams{
ID: int32(expenseUpdate.ID),
UpdatedAt: time.Now(),
Amount: int32(expenseUpdate.Amount),
Currency: expenseUpdate.Currency,
Name: sql.NullString{String: expenseUpdate.Name, Valid: true},
Place: sql.NullString{String: expenseUpdate.Place, Valid: true},
})
if err != nil {
return nil, err
}
return &model.ExpenseEntity{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.CreatedAt,
Amount: int(expenseDTO.Amount),
Currency: expenseDTO.Currency,
EventID: int(expenseDTO.EventID),
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
}, nil
}

View File

@ -0,0 +1,96 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// 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 (
"database/sql"
"encoding/json"
"testing"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/stretchr/testify/assert"
)
func TestConvToExpenseRetrieved(t *testing.T) {
input := &sqlc.GetExpenseByIDRow{
ID: 123,
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Amount: 123,
Currency: "EUR",
EventID: 123,
Name: sql.NullString{Valid: false},
Place: sql.NullString{Valid: false},
Payments: json.RawMessage(
`[{"payer_id": 1, "payer_first_name": "toto", "payer_last_name": "titi", "amount": 10, "currency": "EUR"},
{"payer_id": 2, "payer_first_name": "tata", "payer_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
),
Benefits: json.RawMessage(
`[{"recipient_id": 1, "recipient_first_name": "toto", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"},
{"recipient_id": 2, "recipient_first_name": "tata", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
),
}
want := &model.ExpenseRetrieved{
ID: 123,
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Amount: model.Money{Amount: 123, Currency: model.Currency("EUR")},
EventID: 123,
Detail: model.ExpenseDetail{},
Payments: []model.Payment{
{
PayerID: 1,
PayerFirstName: "toto",
PayerLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
{
PayerID: 2,
PayerFirstName: "tata",
PayerLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
},
Benefits: []model.Benefit{
{
RecipientID: 1,
RecipientFirstName: "toto",
RecipientLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
{
RecipientID: 2,
RecipientFirstName: "tata",
RecipientLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
},
}
got, err := convToExpenseRetrieved(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}

View File

@ -56,3 +56,9 @@ GROUP BY
e.total_amount, e.default_currency,
o.id, o.first_name, o.last_name;
-- name: UpdateEventByID :exec
UPDATE "event"
SET name = $2, description = $3, updated_at = $4
WHERE id = $1;

View File

@ -172,3 +172,26 @@ func (q *Queries) ListEventsByUserID(ctx context.Context, userID int32) ([]ListE
}
return items, nil
}
const updateEventByID = `-- name: UpdateEventByID :exec
UPDATE "event"
SET name = $2, description = $3, updated_at = $4
WHERE id = $1
`
type UpdateEventByIDParams struct {
ID int32
Name string
Description sql.NullString
UpdatedAt time.Time
}
func (q *Queries) UpdateEventByID(ctx context.Context, arg UpdateEventByIDParams) error {
_, err := q.db.ExecContext(ctx, updateEventByID,
arg.ID,
arg.Name,
arg.Description,
arg.UpdatedAt,
)
return err
}

View File

@ -0,0 +1,64 @@
-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;
-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1;
-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1;
-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING *;
-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1;
-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1;

View File

@ -0,0 +1,223 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: expense.sql
package sqlc
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const deleteExpense = `-- name: DeleteExpense :exec
DELETE FROM "expense" WHERE id = $1
`
func (q *Queries) DeleteExpense(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteExpense, id)
return err
}
const deleteTransactionsOfExpenseID = `-- name: DeleteTransactionsOfExpenseID :exec
DELETE FROM "transaction" WHERE transaction.expense_id = $1
`
func (q *Queries) DeleteTransactionsOfExpenseID(ctx context.Context, expenseID int32) error {
_, err := q.db.ExecContext(ctx, deleteTransactionsOfExpenseID, expenseID)
return err
}
const getExpenseByID = `-- name: GetExpenseByID :one
WITH payer_transaction as (
SELECT pt.expense_id,
json_agg(json_build_object(
'payer_id', p.id,
'payer_first_name', p.first_name,
'payer_last_name', p.last_name,
'amount', pt.amount,
'currency', pt.currency
)) AS payments
FROM "transaction" pt
JOIN "user" p ON p.id = pt.user_id
WHERE pt.is_income = FALSE
GROUP BY pt.expense_id
), -- For each expense, aggregate payment info
recipient_transaction as (
SELECT rt.expense_id,
json_agg(json_build_object(
'recipient_id', p.id,
'recipient_first_name', p.first_name,
'recipient_last_name', p.last_name,
'amount', rt.amount,
'currency', rt.currency
)) AS benefits
FROM "transaction" rt
JOIN "user" p ON p.id = rt.user_id
WHERE rt.is_income = TRUE
GROUP BY rt.expense_id
) -- For each expense, aggregate benefits info
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place,
COALESCE(pt.payments, '[]') AS payments,
COALESCE(rt.benefits, '[]') AS benefits
FROM "expense" ex
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
WHERE ex.id = $1
`
type GetExpenseByIDRow struct {
ID int32
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
Payments json.RawMessage
Benefits json.RawMessage
}
func (q *Queries) GetExpenseByID(ctx context.Context, id int32) (GetExpenseByIDRow, error) {
row := q.db.QueryRowContext(ctx, getExpenseByID, id)
var i GetExpenseByIDRow
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
&i.Payments,
&i.Benefits,
)
return i, err
}
const insertExpense = `-- name: InsertExpense :one
INSERT INTO "expense" (
created_at, updated_at, amount, currency, event_id, name, place
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type InsertExpenseParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
}
func (q *Queries) InsertExpense(ctx context.Context, arg InsertExpenseParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, insertExpense,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.EventID,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}
const listExpensesByEventID = `-- name: ListExpensesByEventID :many
SELECT
ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
ex.name, ex.place
FROM "expense" ex
JOIN "event" ev ON ev.id = ex.event_id
WHERE ev.id = $1
`
func (q *Queries) ListExpensesByEventID(ctx context.Context, id int32) ([]Expense, error) {
rows, err := q.db.QueryContext(ctx, listExpensesByEventID, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Expense
for rows.Next() {
var i Expense
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateExpenseByID = `-- name: UpdateExpenseByID :one
UPDATE "expense"
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
WHERE id = $1
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
`
type UpdateExpenseByIDParams struct {
ID int32
UpdatedAt time.Time
Amount int32
Currency string
Name sql.NullString
Place sql.NullString
}
func (q *Queries) UpdateExpenseByID(ctx context.Context, arg UpdateExpenseByIDParams) (Expense, error) {
row := q.db.QueryRowContext(ctx, updateExpenseByID,
arg.ID,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.Name,
arg.Place,
)
var i Expense
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Amount,
&i.Currency,
&i.EventID,
&i.Name,
&i.Place,
)
return i, err
}

View File

@ -5,6 +5,7 @@
package sqlc
import (
"database/sql"
"time"
)
@ -15,6 +16,48 @@ type Admin struct {
AccessLevel int32
}
type Event struct {
ID int32
Name string
Description sql.NullString
DefaultCurrency string
OwnerID int32
CreatedAt time.Time
UpdatedAt time.Time
TotalAmount sql.NullInt32
}
type Expense struct {
ID int32
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
EventID int32
Name sql.NullString
Place sql.NullString
}
type Participation struct {
ID int32
UserID int32
EventID int32
InvitedByUserID sql.NullInt32
CreatedAt time.Time
UpdatedAt time.Time
}
type Transaction struct {
ID int32
ExpenseID int32
UserID int32
Amount int32
Currency string
IsIncome bool
CreatedAt time.Time
UpdatedAt time.Time
}
type User struct {
ID int32
Email string

View File

@ -3,3 +3,8 @@ INSERT INTO participation (
user_id, event_id, invited_by_user_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetParticipation :one
SELECT id, user_id, event_id, invited_by_user_id, created_at, updated_at
FROM "participation"
WHERE user_id = $1 AND event_id = $2;

View File

@ -11,6 +11,31 @@ import (
"time"
)
const getParticipation = `-- name: GetParticipation :one
SELECT id, user_id, event_id, invited_by_user_id, created_at, updated_at
FROM "participation"
WHERE user_id = $1 AND event_id = $2
`
type GetParticipationParams struct {
UserID int32
EventID int32
}
func (q *Queries) GetParticipation(ctx context.Context, arg GetParticipationParams) (Participation, error) {
row := q.db.QueryRowContext(ctx, getParticipation, arg.UserID, arg.EventID)
var i Participation
err := row.Scan(
&i.ID,
&i.UserID,
&i.EventID,
&i.InvitedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertParticipation = `-- name: InsertParticipation :one
INSERT INTO participation (
user_id, event_id, invited_by_user_id, created_at, updated_at

View File

@ -0,0 +1,30 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
package sqlc
import (
"context"
)
type Querier interface {
DeleteExpense(ctx context.Context, id int32) error
DeleteTransactionsOfExpenseID(ctx context.Context, expenseID int32) error
GetEventByID(ctx context.Context, id int32) (GetEventByIDRow, error)
GetExpenseByID(ctx context.Context, id int32) (GetExpenseByIDRow, error)
GetParticipation(ctx context.Context, arg GetParticipationParams) (Participation, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
GetUserByID(ctx context.Context, id int32) (User, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (Event, error)
InsertExpense(ctx context.Context, arg InsertExpenseParams) (Expense, error)
InsertParticipation(ctx context.Context, arg InsertParticipationParams) (Participation, error)
InsertTransaction(ctx context.Context, arg InsertTransactionParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
ListEventsByUserID(ctx context.Context, userID int32) ([]ListEventsByUserIDRow, error)
ListExpensesByEventID(ctx context.Context, id int32) ([]Expense, error)
UpdateEventByID(ctx context.Context, arg UpdateEventByIDParams) error
UpdateExpenseByID(ctx context.Context, arg UpdateExpenseByIDParams) (Expense, error)
}
var _ Querier = (*Queries)(nil)

View File

@ -0,0 +1,5 @@
-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;

View File

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: transaction.sql
package sqlc
import (
"context"
"time"
)
const insertTransaction = `-- name: InsertTransaction :exec
INSERT INTO "transaction" (
created_at, updated_at, amount, currency, expense_id, user_id, is_income
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, expense_id, user_id, amount, currency, is_income, created_at, updated_at
`
type InsertTransactionParams struct {
CreatedAt time.Time
UpdatedAt time.Time
Amount int32
Currency string
ExpenseID int32
UserID int32
IsIncome bool
}
func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) error {
_, err := q.db.ExecContext(ctx, insertTransaction,
arg.CreatedAt,
arg.UpdatedAt,
arg.Amount,
arg.Currency,
arg.ExpenseID,
arg.UserID,
arg.IsIncome,
)
return err
}

View File

@ -1,25 +1,3 @@
-- MIT License
--
-- Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
--
-- 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.
-- name: InsertUser :one
INSERT INTO "user" (
email, first_name, last_name, password, created_at, updated_at

View File

@ -53,7 +53,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) {
}
const insertUser = `-- name: InsertUser :one
INSERT INTO "user" (
email, first_name, last_name, password, created_at, updated_at
) VALUES ( $1, $2, $3, $4, $5, $6 )
@ -69,27 +68,6 @@ type InsertUserParams struct {
UpdatedAt time.Time
}
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// 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.
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, insertUser,
arg.Email,

View File

@ -31,52 +31,42 @@ import (
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"github.com/jackc/pgx/v5"
)
type userRepository struct {
db *sql.DB
queries *sqlc.Queries
}
const insertTimeout = 1 * time.Second
func NewUserRepository(db *sql.DB) repo.UserRepository {
return &userRepository{
db: db,
queries: sqlc.New(db),
}
}
// Create
func (ur *userRepository) Create(
func (u *userRepository) Create(
ctx context.Context,
transaction interface{},
u *model.UserPO,
) (*model.UserPO, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, insertTimeout)
userEntity *model.UserEntity,
tx any,
) (*model.UserEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
args := sqlc.InsertUserParams{
Email: u.Email,
FirstName: u.FirstName,
LastName: u.LastName,
Password: u.Password,
queries := getQueries(u.queries, tx)
userDB, err := queries.InsertUser(timeoutCtx, sqlc.InsertUserParams{
Email: userEntity.Email,
FirstName: userEntity.FirstName,
LastName: userEntity.LastName,
Password: userEntity.Password,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
tx, ok := transaction.(*sql.Tx)
if !ok {
return nil, errors.New("transaction is not a pgx.Tx")
}
queries := sqlc.New(tx)
userDB, err := queries.InsertUser(timeoutCtx, args)
})
if err != nil {
return nil, err
}
return &model.UserPO{
return &model.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,
@ -88,17 +78,25 @@ func (ur *userRepository) Create(
}
// GetByEmail if not found, return nil for user but not error.
func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model.UserPO, error) {
queries := sqlc.New(ur.db)
userDB, err := queries.GetUserByEmail(ctx, email)
if errors.Is(err, pgx.ErrNoRows) {
func (u *userRepository) GetByEmail(
ctx context.Context,
email string,
tx any,
) (*model.UserEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(u.queries, tx)
userDB, err := queries.GetUserByEmail(timeoutCtx, email)
if errors.Is(err, sql.ErrNoRows) {
// No query error, but user not found
return nil, nil
} else if err != nil {
return nil, err
}
return &model.UserPO{
return &model.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,
@ -109,17 +107,21 @@ func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model.
}, 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) {
func (u *userRepository) GetByID(ctx context.Context, id int, tx any) (*model.UserEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(u.queries, tx)
userDB, err := queries.GetUserByID(timeoutCtx, int32(id))
if errors.Is(err, sql.ErrNoRows) {
// No query error, but user not found
return nil, nil
} else if err != nil {
return nil, err
}
return &model.UserPO{
return &model.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,

View File

@ -24,9 +24,9 @@ package model
import "time"
// {{{ DTO Data Transfer Object (from controller to service)
// {{{ Request Object (from controller to service)
type EventCreateDTO struct {
type EventCreateRequest struct {
Name string `json:"name" binding:"requiered"`
Description string `json:"description"`
OwnerID int `json:"owner_id" binding:"requiered,number"`
@ -34,9 +34,17 @@ type EventCreateDTO struct {
}
// }}}
// {{{ VO View Object (from service to controller)
// {{{ Response View Object (from service to controller)
type EventInfoVO struct {
type EventListResponse struct {
ID int
Name string
Description string
Owner *UserBaseResponse
CreatedAt time.Time
}
type EventInfoResponse struct {
ID int
Name string
@ -44,18 +52,18 @@ type EventInfoVO struct {
TotalAmount Money
Owner *UserBaseVO
Owner *UserBaseResponse
CreatedAt time.Time
UpdatedAt time.Time
Users []*UserBaseVO
Users []UserBaseResponse
}
// }}}
// {{{ PO Persistant Object (Between the service and the repository)
// {{{ Entity (DB In)
type EventPO struct {
type EventEntity struct {
ID int
Name string
@ -68,19 +76,54 @@ type EventPO struct {
UpdatedAt time.Time
}
type EventUpdateEntity struct {
ID int
Name string
Description string
CreatedAt time.Time
// TODO: maybe I can change owner too
}
// }}}
// {{{ Retrieved (DB out)
type EventRetrieved struct {
ID int
Name string
Description string
Users []UserBaseRetrieved
TotalAmount Money
DefaultCurrency Currency
Owner *UserBaseRetrieved
CreatedAt time.Time
UpdatedAt time.Time
}
type EventListRetrieved struct {
ID int
Name string
Description string
CreatedAt time.Time
Owner *UserBaseRetrieved
}
// }}}
// {{{ DO Domain Object (Contains the domain service)
type EventDO struct {
type Event struct {
ID int
Name string
Description string
// lazy get using participation join
Users []*UserDO
Users []UserDO
// lazy get
Expenses []*Expense
Expenses []Expense
TotalAmount Money
DefaultCurrency Currency

View File

@ -24,16 +24,63 @@ package model
import "time"
type ExpenseDTO struct {
Amount Money `json:"money" binding:"required,number"`
PayerIDs []int `json:"payer_ids" binding:"required"`
RecipientIDs []int `json:"recipient_ids" binding:"required"`
// {{{ Requrest
type ExpenseRequest struct {
Amount Money `json:"money" binding:"required"`
Payments []Payment `json:"payments" binding:"required"`
Benefits []Benefit `json:"benefits" binding:"required"`
EventID int `json:"event_id" binding:"required"`
Detail ExpenseDetail `json:"detail"`
}
type ExpensePO struct {
// }}}
// {{{ Response
type (
ExpenseGetResponse Expense
ExpenseResponse ExpenseRetrieved
)
// }}}
// {{{ Retrieved
type ExpenseRetrieved Expense
type ExpensesListRetrieved struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
}
type PaymentRetrieved struct {
PayerID int `json:"payer_id"`
PayerFirstName string `json:"payer_first_name"`
PayerLastName string `json:"payer_last_name"`
Amount int `json:"amount"`
Currency string `json:"currency"`
}
type BenefitRetrieved struct {
RecipientID int `json:"recipient_id"`
RecipientFirstName string `json:"recipient_first_name"`
RecipientLastName string `json:"recipient_last_name"`
Amount int `json:"amount"`
Currency string `json:"currency"`
}
// }}}
// {{{ Entity
type ExpenseEntity struct {
ID int
CreatedAt time.Time
UpdatedAt time.Time
Amount int
Currency string
@ -42,30 +89,54 @@ type ExpensePO struct {
// ExpenseDetail
Name string
Place string
CreatedAt time.Time
UpdatedAt time.Time
}
type ExpenseUpdateEntity struct {
ID int
UpdatedAt time.Time
Amount int
Currency string
// Expense Detail
Name string
Place string
}
// }}}
// {{{ Domain Models
type ExpenseDetail struct {
Name string `json:"name"`
Place string `json:"place"`
}
type Expense struct {
ID int
Amount Money
// Lazy aggregate using Transaction join
PayerIDs []int
// Lazy aggregate using Transaction join
RecipientIDs []int
EventID int
Detail ExpenseDetail
CreatedAt time.Time
UpdatedAt time.Time
type Payment struct {
PayerID int `json:"payer_id" binding:"required,number"`
PayerFirstName string `json:"payer_first_name"`
PayerLastName string `json:"payer_last_name"`
Amount Money `json:"amount" binding:"required"`
}
type Benefit struct {
RecipientID int `json:"recipient_id" binding:"required,number"`
RecipientFirstName string `json:"recipient_first_name"`
RecipientLastName string `json:"recipient_last_name"`
Amount Money `json:"amount" binding:"required"`
}
type Expense struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Amount Money `json:"money"`
EventID int `json:"event_id"`
Detail ExpenseDetail `json:"detail"`
Payments []Payment `json:"payments"`
Benefits []Benefit `json:"benefits"`
}
// }}}

View File

@ -24,7 +24,7 @@ package model
import "time"
type ParticipationPO Participation
type ParticipationEntity Participation
// Participation is the association between Users and Events
type Participation struct {

View File

@ -24,18 +24,25 @@ package model
import "time"
type TransactionPO Transaction
// {{{ Entity
type TransactionEntity Transaction
// }}}
// {{{ Domain object
// Transaction is the association between Expenses and Users
type Transaction struct {
ID int
ExpenseID Expense
ExpenseID int
UserID int
Amount int
Currency string
IsIncome bool
IsIncome bool // To note that the direction of the money (payment or income)
CreatedAt time.Time
UpdatedAt time.Time
}
// }}}
// Transaction is the association between Expenses and Users

View File

@ -24,31 +24,27 @@ package model
import "time"
// {{{ DTO Data Transfer Object (from controller to service)
// {{{ Request (from controller to service)
type UserCreateDTO struct {
type UserCreateRequest struct {
Email string `json:"email" binding:"required,email"`
FirstName string `json:"first_name" binding:"required"`
LastName string `json:"last_name" binding:"required"`
Password string `json:"password" binding:"required"`
}
type UserExistDTO struct {
type UserExistRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
// }}}
// {{{ VO View Object (from service to controller)
// {{{ Response View Object (from service to controller)
type UserBaseVO struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
type UserBaseResponse UserBaseRetrieved
type UserInfoVO struct {
// UserBaseVO
type UserInfoResponse struct {
// UserBaseResponse
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
@ -59,9 +55,9 @@ type UserInfoVO struct {
}
// }}}
// {{{ PO Persistant Object (Between the service and the repository)
// {{{ Entity (DB In)
type UserPO struct {
type UserEntity struct {
ID int
Email string
@ -73,6 +69,15 @@ type UserPO struct {
UpdatedAt time.Time
}
// }}}
// {{{ Retrieved (DB out)
type UserBaseRetrieved struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// }}}
// {{{ DO Domain Object (Contains the domain service)

View File

@ -22,7 +22,9 @@
package repo
import "context"
import (
"context"
)
type DBRepository interface {
Transaction(

View File

@ -29,36 +29,46 @@ import (
)
type EventRepository interface {
Create(ctx context.Context, evPO *model.EventPO) (*model.EventPO, error)
Create(ctx context.Context, evEntity *model.EventEntity, tx any) (*model.EventEntity, error)
// UpdateInfo updates the event related information (name, descriptions)
UpdateInfo()
// UpdateEventByID updates the event related information (name, descriptions)
UpdateEventByID(ctx context.Context, event *model.EventUpdateEntity, tx any) error
Delete() // XXX: Pay attention to the foreign key relationships
GetByID()
ListExpensesByUserID()
GetByID(ctx context.Context, eventID int, tx any) (*model.EventRetrieved, error)
// related to events of a user
ListEventsByUserID()
ListEventsByUserID(ctx context.Context, userID int, tx any) ([]model.EventListRetrieved, error)
InsertParticipation(
ctx context.Context,
userID, eventID, invitedByUserID int,
tx any,
) (*model.ParticipationEntity, error)
GetParticipation(
ctx context.Context,
userID, eventID int,
tx any,
) (*model.ParticipationEntity, error)
}
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
DeleteExpense(ctx context.Context, expenseID int, tx any) error
DeleteTransactionsOfExpense(ctx context.Context, expenseID int, tx any) error
GetExpenseByID(ctx context.Context, expenseID int, tx any) (*model.ExpenseRetrieved, error)
InsertExpense(
ctx context.Context,
expenseEntity *model.ExpenseEntity,
tx any,
) (*model.ExpenseEntity, error)
ListExpensesByEventID(
ctx context.Context,
id int,
tx any,
) ([]model.ExpensesListRetrieved, error)
UpdateExpenseByID(
ctx context.Context,
expenseUpdate *model.ExpenseUpdateEntity,
tx any,
) (*model.ExpenseEntity, error)
}

View File

@ -29,7 +29,11 @@ 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)
Create(
ctx context.Context,
u *model.UserEntity,
tx any,
) (*model.UserEntity, error)
GetByEmail(ctx context.Context, email string, tx any) (*model.UserEntity, error)
GetByID(ctx context.Context, id int, tx any) (*model.UserEntity, error)
}

View File

@ -23,8 +23,11 @@
package usecase
import (
"net/http"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
"golang.org/x/net/context"
)
@ -33,12 +36,16 @@ type eventUsecase struct {
userUC User
eventRepo repo.EventRepository
expenseRepo repo.ExpenseRepository
participationRepo repo.ParticipationRepository
transactionRepo repo.TransactionRepository
dbRepo repo.DBRepository
}
var ErrNoParticipation = &errno.Errno{
HTTP: http.StatusUnauthorized,
Code: errno.ErrorCode(errno.AuthFailureCode, "NoParticipation"),
Message: "user doesn't have access to this event",
}
// For the controller
type Event interface{}
@ -46,35 +53,58 @@ 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}
return &eventUsecase{uuc, ev, ex, db}
}
func (evuc *eventUsecase) CreateEvent(
ctx context.Context,
evDTO *model.EventCreateDTO,
) (*model.EventInfoVO, error) {
// transfer evDTO to PO
evRequest *model.EventCreateRequest,
) (*model.EventInfoResponse, error) {
// transfer evRequest to evEntity
evPO := &model.EventPO{
Name: evDTO.Name,
Description: evDTO.Description,
OwnerID: evDTO.OwnerID,
evEntity := &model.EventEntity{
Name: evRequest.Name,
Description: evRequest.Description,
OwnerID: evRequest.OwnerID,
TotalAmount: 0,
DefaultCurrency: string(evDTO.DefaultCurrency),
DefaultCurrency: string(evRequest.DefaultCurrency),
}
data, err := evuc.dbRepo.Transaction(
ctx,
func(txCtx context.Context, tx interface{}) (interface{}, error) {
created, err := evuc.eventRepo.Create(ctx, evPO)
func(txCtx context.Context, tx any) (any, error) {
// Create the event
created, err := evuc.eventRepo.Create(ctx, evEntity, tx)
if err != nil {
return nil, err
}
// participate to the event
participation, err := evuc.eventRepo.InsertParticipation(
ctx,
created.OwnerID,
created.ID,
0,
tx,
)
if err != nil {
return nil, err
}
if participation == nil {
// Unexpected error
log.ErrorLog(
"participation existed for event-user pair",
"userID",
created.OwnerID,
"eventID",
created.ID,
)
return nil, errno.InternalServerErr
}
// TODO: App log, maybe can be sent to some third party service.
log.InfoLog(
"created new event",
@ -84,12 +114,13 @@ func (evuc *eventUsecase) CreateEvent(
created.OwnerID,
)
ownerVO, err := evuc.userUC.GetUserBaseVOByID(ctx, created.OwnerID)
// Construct the response
ownerResponse, err := evuc.userUC.GetUserBaseResponseByID(ctx, created.OwnerID)
if err != nil {
return nil, err
}
evVO := &model.EventInfoVO{
evResponse := &model.EventInfoResponse{
ID: created.ID,
Name: created.Name,
Description: created.Description,
@ -97,16 +128,63 @@ func (evuc *eventUsecase) CreateEvent(
created.TotalAmount,
model.Currency(created.DefaultCurrency),
),
Owner: ownerVO,
Owner: ownerResponse,
CreatedAt: created.CreatedAt,
UpdatedAt: created.UpdatedAt,
Users: []model.UserBaseResponse{*ownerResponse},
}
return evVO, err
})
return evResponse, err
},
)
if err != nil {
return nil, err
}
res := data.(*model.EventInfoVO)
res := data.(*model.EventInfoResponse)
return res, err
}
func (evuc *eventUsecase) ListEvents(
ctx context.Context,
userID int,
) ([]model.EventListResponse, error) {
eventListRetrieved, err := evuc.eventRepo.ListEventsByUserID(ctx, userID, nil)
if err != nil {
return nil, err
}
// Check if the user is a member of the event
responses := make([]model.EventListResponse, len(eventListRetrieved))
for i, retrieved := range eventListRetrieved {
ownner := model.UserBaseResponse(*retrieved.Owner)
res := model.EventListResponse{
ID: retrieved.ID,
Name: retrieved.Name,
Description: retrieved.Description,
Owner: &ownner,
CreatedAt: retrieved.CreatedAt,
}
responses[i] = res
}
return responses, nil
}
// GetEventDetail
func (evuc *eventUsecase) GetEventDetail(
ctx context.Context,
userID, eventID int,
) (*model.EventInfoResponse, error) {
// Check if the user has the right to get this event
// err := evuc.participationRepo.CheckParticipation(ctx, userID, eventID)
// if err != nil {
// return nil, ErrNoParticipation
// }
// Get the eventDetail
// TODO: This can also be put into the cache
return nil, nil
}

View File

@ -22,7 +22,9 @@
package repomock
import "context"
import (
"context"
)
type TestDBRepository struct{}

View File

@ -36,9 +36,9 @@ type TestUserRepository struct{}
func (tur *TestUserRepository) Create(
ctx context.Context,
transaction interface{},
u *model.UserPO,
) (*model.UserPO, error) {
u *model.UserEntity,
tx any,
) (*model.UserEntity, error) {
user := *u
user.ID = 123
@ -53,11 +53,12 @@ func (tur *TestUserRepository) Create(
func (tur *TestUserRepository) GetByEmail(
ctx context.Context,
email string,
) (*model.UserPO, error) {
tx any,
) (*model.UserEntity, error) {
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
switch email {
case "a@b.c":
return &model.UserPO{
return &model.UserEntity{
ID: 123,
Email: "a@b.c",
Password: string(hashedPwd),
@ -71,11 +72,15 @@ func (tur *TestUserRepository) GetByEmail(
return nil, UserTestDummyErr
}
func (tur *TestUserRepository) GetByID(ctx context.Context, id int) (*model.UserPO, error) {
func (tur *TestUserRepository) GetByID(
ctx context.Context,
id int,
tx any,
) (*model.UserEntity, error) {
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
switch id {
case 123:
return &model.UserPO{
return &model.UserEntity{
ID: 123,
Email: "a@b.c",
Password: string(hashedPwd),

View File

@ -60,9 +60,9 @@ type userUsecase struct {
}
type User interface {
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)
Create(ctx context.Context, u *model.UserCreateRequest) (*model.UserInfoResponse, error)
Exist(ctx context.Context, u *model.UserExistRequest) error
GetUserBaseResponseByID(ctx context.Context, userID int) (*model.UserBaseResponse, error)
}
func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
@ -74,8 +74,8 @@ func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
func (uuc *userUsecase) Create(
ctx context.Context,
u *model.UserCreateDTO,
) (*model.UserInfoVO, error) {
u *model.UserCreateRequest,
) (*model.UserInfoResponse, error) {
// Hash the password
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
if err != nil {
@ -86,12 +86,12 @@ func (uuc *userUsecase) Create(
data, err := uuc.dbRepo.Transaction(
ctx,
func(txCtx context.Context, tx interface{}) (interface{}, error) {
created, err := uuc.userRepo.Create(txCtx, tx, &model.UserPO{
created, err := uuc.userRepo.Create(txCtx, &model.UserEntity{
Email: u.Email,
Password: u.Password,
FirstName: u.FirstName,
LastName: u.LastName,
})
}, tx)
if err != nil {
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
if match {
@ -117,22 +117,22 @@ func (uuc *userUsecase) Create(
return nil, err
}
userPO := data.(*model.UserPO)
userEntity := data.(*model.UserEntity)
user := &model.UserInfoVO{
ID: userPO.ID,
Email: userPO.Email,
FirstName: userPO.FirstName,
LastName: userPO.LastName,
CreatedAt: userPO.CreatedAt,
UpdatedAt: userPO.UpdatedAt,
user := &model.UserInfoResponse{
ID: userEntity.ID,
Email: userEntity.Email,
FirstName: userEntity.FirstName,
LastName: userEntity.LastName,
CreatedAt: userEntity.CreatedAt,
UpdatedAt: userEntity.UpdatedAt,
}
return user, nil
}
func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error {
got, err := uuc.userRepo.GetByEmail(ctx, u.Email)
func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistRequest) error {
got, err := uuc.userRepo.GetByEmail(ctx, u.Email, nil)
// Any query error?
if err != nil {
return err
@ -152,15 +152,19 @@ func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error
return nil
}
func (uuc *userUsecase) GetUserBaseVOByID(
func (uuc *userUsecase) GetUserBaseResponseByID(
ctx context.Context,
userID int,
) (*model.UserBaseVO, error) {
got, err := uuc.userRepo.GetByID(ctx, userID)
) (*model.UserBaseResponse, error) {
// TODO: should try first to get from the cache
// If not exists, get from the DB. And then put back
// into the cache with a timeout.
// Refresh the cache when the user data is updated (for now it cannot be updated)
got, err := uuc.userRepo.GetByID(ctx, userID, nil)
if err != nil {
return nil, err
}
userBaseVo := &model.UserBaseVO{
userBaseVo := &model.UserBaseResponse{
ID: got.ID,
FirstName: got.FirstName,
LastName: got.LastName,

View File

@ -35,13 +35,13 @@ func TestCreateUser(t *testing.T) {
t.Run("normal create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
input := &model.UserCreateDTO{
input := &model.UserCreateRequest{
Email: "a@b.c",
FirstName: "James",
LastName: "Bond",
Password: "verystrong",
}
want := &model.UserInfoVO{
want := &model.UserInfoResponse{
ID: 123,
}
@ -53,7 +53,7 @@ func TestCreateUser(t *testing.T) {
t.Run("duplicate create", func(t *testing.T) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
input := &model.UserCreateDTO{
input := &model.UserCreateRequest{
Email: "duplicate@error.com",
FirstName: "James",
LastName: "Bond",
@ -68,22 +68,22 @@ func TestCreateUser(t *testing.T) {
func TestUserExist(t *testing.T) {
testCases := []struct {
Name string
User *model.UserExistDTO
User *model.UserExistRequest
ExpErr error
}{
{"user exists", &model.UserExistDTO{
{"user exists", &model.UserExistRequest{
Email: "a@b.c",
Password: "strongHashed",
}, nil},
{"query error", &model.UserExistDTO{
{"query error", &model.UserExistRequest{
Email: "query@error.com",
Password: "strongHashed",
}, repomock.UserTestDummyErr},
{"user doesn not exist", &model.UserExistDTO{
{"user doesn not exist", &model.UserExistRequest{
Email: "inexist@error.com",
Password: "strongHashed",
}, UserNotExist},
{"wrong password", &model.UserExistDTO{
{"wrong password", &model.UserExistRequest{
Email: "a@b.c",
Password: "wrongHashed",
}, UserWrongPassword},

View File

@ -0,0 +1,14 @@
CREATE TABLE "transaction" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"expense_id" integer NOT NULL,
"user_id" integer NOT NULL,
"amount" integer NOT NULL,
"currency" character varying(255) NOT NULL,
"is_income" boolean NOT NULL DEFAULT FALSE,
"created_at" date NOT NULL,
"updated_at" date NOT NULL
);
ALTER TABLE "transaction"
ADD FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
DROP TABLE "expense";

View File

@ -0,0 +1,14 @@
CREATE TABLE "expense" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"created_at" date NOT NULL,
"updated_at" date NOT NULL,
"amount" integer NOT NULL,
"currency" character varying NOT NULL,
"event_id" integer NOT NULL,
"name" character varying(255) NULL,
"place" character varying(1000) NULL
);
ALTER TABLE "expense"
ADD FOREIGN KEY ("event_id") REFERENCES "event" ("id") ON DELETE CASCADE ON UPDATE CASCADE

View File

@ -0,0 +1 @@
DROP TABLE transaction;

View File

@ -0,0 +1,2 @@
ALTER TABLE "transaction"
ADD FOREIGN KEY ("expense_id") REFERENCES "expense" ("id") ON DELETE CASCADE ON UPDATE CASCADE

View File

@ -28,3 +28,4 @@ sql:
gen:
go:
out: "internal/howmuch/adapter/repo/sqlc"
emit_interface: true

43
web/package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "web",
"version": "0.0.0",
"dependencies": {
"axios": "^1.7.7",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
@ -2299,8 +2300,17 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
@ -2497,7 +2507,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@ -2710,7 +2719,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@ -3256,6 +3264,25 @@
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -3276,7 +3303,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@ -4041,7 +4067,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@ -4050,7 +4075,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@ -4614,6 +4638,11 @@
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",

View File

@ -14,6 +14,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.7.7",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},

View File

@ -37,6 +37,8 @@ import HelloWorld from './components/HelloWorld.vue'
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink to="/login">Login</RouterLink>
<RouterLink to="/Signup">Signup</RouterLink>
</nav>
</div>
</header>

View File

@ -40,6 +40,16 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue')
},
{
path: '/signup',
name: 'signup',
component: () => import('../views/SignupView.vue')
}
]
})

View File

@ -0,0 +1,83 @@
<!--
MIT License
Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
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.
-->
<template>
<div class="login-container">
<h2>Login</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="email">Email:</label>
<input type="text" id="email" v-model="email" required />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" v-model="password" required />
</div>
<button type="submit">Login</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
const email = ref('')
const password = ref('')
const error = ref('')
const router = useRouter()
const handleLogin = async () => {
try {
const response = await axios.post('http://localhost:8000/v1/session/create', {
email: email.value,
password: password.value
})
if (response.status === 200) {
// Clear error message
error.value = ''
// Redirect to dashboard or another route
router.push('/about')
} else {
error.value = 'Invalid email or password'
}
} catch (err) {
error.value = 'An error occurred. Please try again.'
console.error(err)
}
}
</script>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,95 @@
<!--
MIT License
Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
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.
-->
<template>
<div class="signup-container">
<h2>Signup</h2>
<form @submit.prevent="handleSignup">
<div class="form-group">
<label for="email">Email:</label>
<input type="text" id="email" v-model="email" required />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" v-model="password" required />
</div>
<div class="form-group">
<label for="first_name">First Name:</label>
<input type="first_name" id="first_name" v-model="first_name" required />
</div>
<div class="form-group">
<label for="last_name">Last Name:</label>
<input type="last_name" id="last_name" v-model="last_name" required />
</div>
<button type="submit">Signup</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
const first_name = ref('')
const last_name = ref('')
const email = ref('')
const password = ref('')
const error = ref('')
const router = useRouter()
const handleSignup = async () => {
try {
const response = await axios.post('http://localhost:8000/v1/user/create', {
email: email.value,
password: password.value,
first_name: first_name.value,
last_name: last_name.value
})
if (response.status === 200) {
// Clear error message
error.value = ''
// Redirect to dashboard or another route
router.push('/login')
} else {
error.value = 'Failed to signup'
}
} catch (err) {
error.value = 'An error occurred. Please try again.'
console.error(err)
}
}
</script>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>