Compare commits

..

25 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
dde4eb337c repo: add some sql for events
All checks were successful
Build and test / Build (push) Successful in 2m29s
2024-10-18 21:15:27 +02:00
39eaae46d8 db: add migrations 2024-10-18 19:36:31 +02:00
86832cf1f9 test: add wrong params test cases for session create 2024-10-17 22:12:31 +02:00
350a6f86d9 doc: add diary for use cases
All checks were successful
Build and test / Build (push) Successful in 2m21s
2024-10-17 21:56:23 +02:00
a7a915d825 feat: Impl event while refactoring user domain
All checks were successful
Build and test / Build (push) Successful in 2m21s
2024-10-16 23:47:06 +02:00
29633e0e95 feat: use PO for repo layer
All checks were successful
Build and test / Build (push) Successful in 2m26s
2024-10-16 09:59:03 +02:00
0e05924585 feat: rework entities design 2024-10-16 09:49:07 +02:00
dfc2d1b2eb feat: add transaction and participation associations 2024-10-16 09:23:32 +02:00
61 changed files with 2855 additions and 160 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.

206
README.md
View File

@ -19,6 +19,13 @@
- [The choice of the front end framework](#the-choice-of-the-front-end-framework)
- [2024/10/08](#20241008)
- [2024/10/09](#20241009)
- [2024/10/11](#20241011)
- [2024/10/13](#20241013)
- [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
@ -376,3 +383,202 @@ 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.
### 2024/10/17
The following basic use cases are to be implemented at the first time.
- [X] A user signs up
- [X] A user logs in
- [ ] 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 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 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:
- introduce an admin to handle users.
- user info updates
- deleting user
- More user related contents
- Event related contents
- ex. Trip journal...
Stop dreaming... Just do the simple stuff first!
### 2024/10/18
I spent some time to figure out this one! But I don't actually need it for now.
So I just keep it here:
```SQL
SELECT
e.id,
e.name,
e.description,
e.created_at,
json_build_object(
'id', o.id,
'first_name', o.first_name,
'last_name', o.last_name
) AS owner,
json_agg(
json_build_object(
'id', u.id,
'first_name', u.first_name,
'last_name', u.last_name
)
) AS users -- Aggregation for users in the event
FROM "event" e
JOIN "participation" p ON p.event_id = e.id -- participation linked with the event
JOIN "user" u ON u.id = p.user_id -- and the query user
JOIN "user" o ON o.id = e.owner_id -- get the owner info
WHERE e.id IN (
SELECT pt.event_id FROM participation pt WHERE pt.user_id = $1
-- consider the events participated by user_id
)
GROUP BY
e.id, e.name, e.description, e.created_at,
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

@ -85,6 +85,14 @@ func TestSessionCreate(t *testing.T) {
Email: "unregistered@error.com",
Password: "strong password",
}, usecase.UserNotExist},
{"wrong email", createParams{
Email: "error.com",
Password: "strong password",
}, UserParamsErr},
{"no passwrd", createParams{
Email: "no@error.com",
Password: "",
}, UserParamsErr},
}
token.Init("secret", 1*time.Second)

View File

@ -35,11 +35,14 @@ 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.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" {
@ -53,3 +56,11 @@ func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error
// Should never reach here
return nil
}
func (*testUserUsecase) GetUserBaseResponseByID(
ctx context.Context,
userID int,
) (*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

@ -0,0 +1,64 @@
-- name: InsertEvent :one
INSERT INTO "event" (
name, description, total_amount, default_currency, owner_id, created_at, updated_at
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING *;
-- name: ListEventsByUserID :many
SELECT
e.id,
e.name,
e.description,
e.created_at,
json_build_object(
'id', o.id,
'first_name', o.first_name,
'last_name', o.last_name
) AS owner
FROM "event" e
JOIN "participation" p ON p.event_id = e.id -- participation linked with the event
JOIN "user" o ON o.id = e.owner_id -- get the owner info
WHERE e.id IN (
SELECT pt.event_id FROM participation pt WHERE pt.user_id = $1 -- consider the events participated by user_id
)
GROUP BY
e.id, e.name, e.description, e.created_at,
o.id, o.first_name, o.last_name;
-- name: GetEventByID :one
SELECT
e.id,
e.name,
e.description,
e.total_amount,
e.default_currency,
e.created_at,
e.updated_at,
json_build_object(
'id', o.id,
'first_name', o.first_name,
'last_name', o.last_name
) AS owner,
json_agg(
json_build_object(
'id', u.id,
'first_name', u.first_name,
'last_name', u.last_name
)
) AS users -- Aggregation for users in the event
FROM "event" e
JOIN "participation" p ON p.event_id = e.id -- participation linked with the event
JOIN "user" u ON u.id = p.user_id -- and the query user
JOIN "user" o ON o.id = e.owner_id -- get the owner info
WHERE e.id = $1
GROUP BY
e.id, e.name, e.description, e.created_at, e.updated_at,
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

@ -0,0 +1,197 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: event.sql
package sqlc
import (
"context"
"database/sql"
"encoding/json"
"time"
)
const getEventByID = `-- name: GetEventByID :one
SELECT
e.id,
e.name,
e.description,
e.total_amount,
e.default_currency,
e.created_at,
e.updated_at,
json_build_object(
'id', o.id,
'first_name', o.first_name,
'last_name', o.last_name
) AS owner,
json_agg(
json_build_object(
'id', u.id,
'first_name', u.first_name,
'last_name', u.last_name
)
) AS users -- Aggregation for users in the event
FROM "event" e
JOIN "participation" p ON p.event_id = e.id -- participation linked with the event
JOIN "user" u ON u.id = p.user_id -- and the query user
JOIN "user" o ON o.id = e.owner_id -- get the owner info
WHERE e.id = $1
GROUP BY
e.id, e.name, e.description, e.created_at, e.updated_at,
e.total_amount, e.default_currency,
o.id, o.first_name, o.last_name
`
type GetEventByIDRow struct {
ID int32
Name string
Description sql.NullString
TotalAmount sql.NullInt32
DefaultCurrency string
CreatedAt time.Time
UpdatedAt time.Time
Owner json.RawMessage
Users json.RawMessage
}
func (q *Queries) GetEventByID(ctx context.Context, id int32) (GetEventByIDRow, error) {
row := q.db.QueryRowContext(ctx, getEventByID, id)
var i GetEventByIDRow
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.TotalAmount,
&i.DefaultCurrency,
&i.CreatedAt,
&i.UpdatedAt,
&i.Owner,
&i.Users,
)
return i, err
}
const insertEvent = `-- name: InsertEvent :one
INSERT INTO "event" (
name, description, total_amount, default_currency, owner_id, created_at, updated_at
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
RETURNING id, name, description, default_currency, owner_id, created_at, updated_at, total_amount
`
type InsertEventParams struct {
Name string
Description sql.NullString
TotalAmount sql.NullInt32
DefaultCurrency string
OwnerID int32
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) InsertEvent(ctx context.Context, arg InsertEventParams) (Event, error) {
row := q.db.QueryRowContext(ctx, insertEvent,
arg.Name,
arg.Description,
arg.TotalAmount,
arg.DefaultCurrency,
arg.OwnerID,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Event
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.DefaultCurrency,
&i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
&i.TotalAmount,
)
return i, err
}
const listEventsByUserID = `-- name: ListEventsByUserID :many
SELECT
e.id,
e.name,
e.description,
e.created_at,
json_build_object(
'id', o.id,
'first_name', o.first_name,
'last_name', o.last_name
) AS owner
FROM "event" e
JOIN "participation" p ON p.event_id = e.id -- participation linked with the event
JOIN "user" o ON o.id = e.owner_id -- get the owner info
WHERE e.id IN (
SELECT pt.event_id FROM participation pt WHERE pt.user_id = $1 -- consider the events participated by user_id
)
GROUP BY
e.id, e.name, e.description, e.created_at,
o.id, o.first_name, o.last_name
`
type ListEventsByUserIDRow struct {
ID int32
Name string
Description sql.NullString
CreatedAt time.Time
Owner json.RawMessage
}
func (q *Queries) ListEventsByUserID(ctx context.Context, userID int32) ([]ListEventsByUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, listEventsByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListEventsByUserIDRow
for rows.Next() {
var i ListEventsByUserIDRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.CreatedAt,
&i.Owner,
); 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 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

@ -0,0 +1,10 @@
-- name: InsertParticipation :one
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

@ -0,0 +1,72 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.27.0
// source: participation.sql
package sqlc
import (
"context"
"database/sql"
"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
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, event_id, invited_by_user_id, created_at, updated_at
`
type InsertParticipationParams struct {
UserID int32
EventID int32
InvitedByUserID sql.NullInt32
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) InsertParticipation(ctx context.Context, arg InsertParticipationParams) (Participation, error) {
row := q.db.QueryRowContext(ctx, insertParticipation,
arg.UserID,
arg.EventID,
arg.InvitedByUserID,
arg.CreatedAt,
arg.UpdatedAt,
)
var i Participation
err := row.Scan(
&i.ID,
&i.UserID,
&i.EventID,
&i.InvitedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

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
@ -30,3 +8,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;

View File

@ -31,8 +31,28 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
return i, err
}
const insertUser = `-- name: InsertUser :one
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" (
email, first_name, last_name, password, created_at, updated_at
) VALUES ( $1, $2, $3, $4, $5, $6 )
@ -48,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.User,
) (*model.User, 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.User{
return &model.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,
@ -88,17 +78,50 @@ 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.User, 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.User{
return &model.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,
LastName: userDB.LastName,
Password: userDB.Password,
CreatedAt: userDB.CreatedAt,
UpdatedAt: userDB.CreatedAt,
}, nil
}
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.UserEntity{
ID: int(userDB.ID),
Email: userDB.Email,
FirstName: userDB.FirstName,

View File

@ -24,15 +24,113 @@ package model
import "time"
type Event struct {
ID int
// {{{ Request Object (from controller to service)
type EventCreateRequest struct {
Name string `json:"name" binding:"requiered"`
Description string `json:"description"`
OwnerID int `json:"owner_id" binding:"requiered,number"`
DefaultCurrency Currency `json:"currency"`
}
// }}}
// {{{ Response View Object (from service to controller)
type EventListResponse struct {
ID int
Name string
Description string
Owner *UserBaseResponse
CreatedAt time.Time
}
type EventInfoResponse struct {
ID int
Name string
Description string
TotalAmount Money
Owner *UserBaseResponse
CreatedAt time.Time
UpdatedAt time.Time
Users []UserBaseResponse
}
// }}}
// {{{ Entity (DB In)
type EventEntity struct {
ID int
Name string
Description string
Users []*User
Expenses []*Expense
TotalAmount int
DefaultCurrency string
OwnerID int
CreatedAt time.Time
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
CreatedBy User
CreatedAt time.Time
UpdatedAt time.Time
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 Event struct {
ID int
Name string
Description string
// lazy get using participation join
Users []UserDO
// lazy get
Expenses []Expense
TotalAmount Money
DefaultCurrency Currency
Owner *UserDO
CreatedAt time.Time
UpdatedAt time.Time
}
// }}}

View File

@ -24,19 +24,119 @@ package model
import "time"
type ExpenseDetail struct {
// {{{ 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"`
}
// }}}
// {{{ 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
EventID int
// ExpenseDetail
Name string
Place string
}
type Expense struct {
ID int
Amount Money
Currency Currency
PayerIDs []int
RecipientIDs []int
EventID int
Detail ExpenseDetail
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 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

@ -36,8 +36,8 @@ const (
)
type Money struct {
ammount int
currency Currency
Amount int `json:"amount" binding:"required,number"`
Currency Currency `json:"currency" binding:"required"`
}
func MakeMoney(amount int, currency Currency) Money {
@ -46,10 +46,10 @@ func MakeMoney(amount int, currency Currency) Money {
func Add(cur Currency, money ...Money) Money {
var sum Money
sum.currency = cur
sum.Currency = cur
for _, m := range money {
sum.ammount += m.ammount
sum.Amount += m.Amount
}
return sum
@ -58,9 +58,9 @@ func Add(cur Currency, money ...Money) Money {
func Diff(cur Currency, money1 Money, money2 Money) Money {
var diff Money
diff.currency = cur
diff.Currency = cur
diff.ammount = money1.ammount - money2.ammount
diff.Amount = money1.Amount - money2.Amount
return diff
}

View File

@ -0,0 +1,39 @@
// 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 model
import "time"
type ParticipationEntity Participation
// Participation is the association between Users and Events
type Participation struct {
ID int
UserID int
EventID int
InvitedByUserID int
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@ -0,0 +1,48 @@
// 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 model
import "time"
// {{{ Entity
type TransactionEntity Transaction
// }}}
// {{{ Domain object
type Transaction struct {
ID int
ExpenseID int
UserID int
Amount int
Currency string
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,25 +24,77 @@ package model
import "time"
type UserCreateDTO struct {
// {{{ Request (from controller to service)
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"`
}
// User model
type User struct {
ID int
// }}}
// {{{ Response View Object (from service to controller)
type UserBaseResponse UserBaseRetrieved
type UserInfoResponse struct {
// UserBaseResponse
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
CreatedAt time.Time
UpdatedAt time.Time
}
// }}}
// {{{ Entity (DB In)
type UserEntity struct {
ID int
Email string
FirstName string
LastName string
Password string
CreatedAt time.Time
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)
// TODO: For now I don't know what to do with this model
type UserDO struct {
ID int
Email string
FirstName string
LastName string
Password string
// Lazy aggregate with the Participation join
EventIDs []int
CreatedAt time.Time
UpdatedAt time.Time
}
// }}}

View File

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

View File

@ -0,0 +1,74 @@
// 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"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
)
type EventRepository interface {
Create(ctx context.Context, evEntity *model.EventEntity, tx any) (*model.EventEntity, error)
// UpdateEventByID updates the event related information (name, descriptions)
UpdateEventByID(ctx context.Context, event *model.EventUpdateEntity, tx any) error
GetByID(ctx context.Context, eventID int, tx any) (*model.EventRetrieved, error)
// related to events of a user
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 {
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,6 +29,11 @@ import (
)
type UserRepository interface {
Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, 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

@ -0,0 +1,190 @@
// 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 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"
)
type eventUsecase struct {
userUC User
eventRepo repo.EventRepository
expenseRepo repo.ExpenseRepository
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{}
func NewEventUsecase(
uuc User,
ev repo.EventRepository,
ex repo.ExpenseRepository,
db repo.DBRepository,
) Event {
return &eventUsecase{uuc, ev, ex, db}
}
func (evuc *eventUsecase) CreateEvent(
ctx context.Context,
evRequest *model.EventCreateRequest,
) (*model.EventInfoResponse, error) {
// transfer evRequest to evEntity
evEntity := &model.EventEntity{
Name: evRequest.Name,
Description: evRequest.Description,
OwnerID: evRequest.OwnerID,
TotalAmount: 0,
DefaultCurrency: string(evRequest.DefaultCurrency),
}
data, err := evuc.dbRepo.Transaction(
ctx,
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",
"name",
created.Name,
"owner",
created.OwnerID,
)
// Construct the response
ownerResponse, err := evuc.userUC.GetUserBaseResponseByID(ctx, created.OwnerID)
if err != nil {
return nil, err
}
evResponse := &model.EventInfoResponse{
ID: created.ID,
Name: created.Name,
Description: created.Description,
TotalAmount: model.MakeMoney(
created.TotalAmount,
model.Currency(created.DefaultCurrency),
),
Owner: ownerResponse,
CreatedAt: created.CreatedAt,
UpdatedAt: created.UpdatedAt,
Users: []model.UserBaseResponse{*ownerResponse},
}
return evResponse, err
},
)
if err != nil {
return nil, err
}
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.User,
) (*model.User, error) {
u *model.UserEntity,
tx any,
) (*model.UserEntity, error) {
user := *u
user.ID = 123
@ -50,11 +50,15 @@ func (tur *TestUserRepository) Create(
return &user, nil
}
func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
func (tur *TestUserRepository) GetByEmail(
ctx context.Context,
email string,
tx any,
) (*model.UserEntity, error) {
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
switch email {
case "a@b.c":
return &model.User{
return &model.UserEntity{
ID: 123,
Email: "a@b.c",
Password: string(hashedPwd),
@ -67,3 +71,24 @@ func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*mo
return nil, UserTestDummyErr
}
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.UserEntity{
ID: 123,
Email: "a@b.c",
Password: string(hashedPwd),
}, nil
case 456:
return nil, UserTestDummyErr
case 789:
return nil, nil
}
return nil, UserTestDummyErr
}

View File

@ -60,8 +60,9 @@ type userUsecase struct {
}
type User interface {
Create(ctx context.Context, u *model.UserCreateDTO) (*model.User, error)
Exist(ctx context.Context, u *model.UserExistDTO) 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 {
@ -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.UserCreateRequest,
) (*model.UserInfoResponse, error) {
// Hash the password
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
if err != nil {
@ -82,12 +86,12 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*mo
data, err := uuc.dbRepo.Transaction(
ctx,
func(txCtx context.Context, tx interface{}) (interface{}, error) {
created, err := uuc.userRepo.Create(txCtx, tx, &model.User{
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 {
@ -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
@ -113,13 +117,22 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*mo
return nil, err
}
user := data.(*model.User)
userEntity := data.(*model.UserEntity)
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
@ -138,3 +151,23 @@ func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error
return nil
}
func (uuc *userUsecase) GetUserBaseResponseByID(
ctx context.Context,
userID int,
) (*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.UserBaseResponse{
ID: got.ID,
FirstName: got.FirstName,
LastName: got.LastName,
}
return userBaseVo, nil
}

View File

@ -29,42 +29,31 @@ 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) {
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.User{
ID: 123,
Email: input.Email,
FirstName: input.FirstName,
LastName: input.LastName,
// Password is hashed
Password: "verystrong",
want := &model.UserInfoResponse{
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) {
ctx := context.Background()
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
input := &model.UserCreateDTO{
input := &model.UserCreateRequest{
Email: "duplicate@error.com",
FirstName: "James",
LastName: "Bond",
@ -79,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 @@
DROP TABLE "event"

View File

@ -0,0 +1,10 @@
CREATE TABLE "event" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"name" character varying(255) NOT NULL,
"description" character varying(10000) NULL,
"default_currency" character varying(255) NOT NULL,
"owner_id" integer NOT NULL,
"created_at" date NOT NULL,
"updated_at" date NOT NULL
);

View File

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

View File

@ -0,0 +1,16 @@
CREATE TABLE "participation" (
"id" serial NOT NULL,
PRIMARY KEY ("id"),
"user_id" integer NOT NULL,
"event_id" integer NOT NULL,
"invited_by_user_id" integer NULL,
"created_at" date NOT NULL,
"updated_at" date NOT NULL
);
ALTER TABLE "participation"
ADD FOREIGN KEY ("event_id") REFERENCES "event" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "participation"
ADD FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
ALTER TABLE "event"
ADD "total_amount" integer NULL;

View File

@ -0,0 +1,2 @@
ALTER TABLE "participation"
ADD CONSTRAINT unique_user_event UNIQUE ("user_id", "event_id");

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>