Compare commits
40 Commits
addddb152a
...
main
Author | SHA1 | Date | |
---|---|---|---|
dd999b9355 | |||
14ee642aab | |||
b30a5c5c2d | |||
58cff774e6 | |||
716a58d44c | |||
de7c6f7223 | |||
0258ff6620 | |||
0da8b29507 | |||
304651e7ff | |||
74ae6b7877 | |||
b4259e9a51 | |||
46c14b63ea | |||
c27dfc687f | |||
3d616bff50 | |||
dac36db284 | |||
80a5f1f8a8 | |||
a55fd26f90 | |||
dde4eb337c | |||
39eaae46d8 | |||
86832cf1f9 | |||
350a6f86d9 | |||
a7a915d825 | |||
29633e0e95 | |||
0e05924585 | |||
dfc2d1b2eb | |||
3b18a15494 | |||
c94b0b532b | |||
606289be1a | |||
382da3d811 | |||
322b441c70 | |||
20ef75b1aa | |||
1fb84a3ff4 | |||
2fe834fe55 | |||
544ccbe1ca | |||
9290bcf88c | |||
7ff91bab1d | |||
ca2985abb4 | |||
798b9a7695 | |||
71926b2197 | |||
79739e3751 |
@ -7,7 +7,7 @@ tmp_dir = "tmp"
|
|||||||
bin = "./_output/howmuch"
|
bin = "./_output/howmuch"
|
||||||
cmd = "make build"
|
cmd = "make build"
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output"]
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output", "internal/howmuch/adapter/repo/sqlc"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -24,7 +24,7 @@ go.work.sum
|
|||||||
|
|
||||||
# Custom
|
# Custom
|
||||||
/_output
|
/_output
|
||||||
/deployment/db_data
|
/deployment/tmp
|
||||||
/tmp/**
|
/tmp/**
|
||||||
|
|
||||||
# Vue
|
# Vue
|
||||||
|
4
Makefile
4
Makefile
@ -34,7 +34,7 @@ web: web-all
|
|||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: tidy sqlc # build.
|
build: tidy sqlc # build.
|
||||||
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null
|
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go
|
||||||
|
|
||||||
.PHONY: sqlc
|
.PHONY: sqlc
|
||||||
sqlc:
|
sqlc:
|
||||||
@ -46,7 +46,7 @@ format: # format code.
|
|||||||
|
|
||||||
.PHONY: add-copyright
|
.PHONY: add-copyright
|
||||||
add-copyright: # add license to file headers.
|
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
|
.PHONY: swagger
|
||||||
swagger: # Run swagger.
|
swagger: # Run swagger.
|
||||||
|
240
README.md
240
README.md
@ -19,6 +19,13 @@
|
|||||||
- [The choice of the front end framework](#the-choice-of-the-front-end-framework)
|
- [The choice of the front end framework](#the-choice-of-the-front-end-framework)
|
||||||
- [2024/10/08](#20241008)
|
- [2024/10/08](#20241008)
|
||||||
- [2024/10/09](#20241009)
|
- [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-->
|
<!--toc:end-->
|
||||||
|
|
||||||
A tricount like expense-sharing system written in Go
|
A tricount like expense-sharing system written in Go
|
||||||
@ -342,3 +349,236 @@ front-end for now and concentrate on my backend Go app.
|
|||||||
For now, I will just test my backend with `curl`.
|
For now, I will just test my backend with `curl`.
|
||||||
|
|
||||||
And today's job is to get the login part done!
|
And today's job is to get the login part done!
|
||||||
|
|
||||||
|
### 2024/10/13
|
||||||
|
|
||||||
|
Finally it took more than just one night for me to figure out the JWT.
|
||||||
|
|
||||||
|
The JWT token is simple because it doesn't need to be stored to and fetched
|
||||||
|
from a database. But there is no way to revoke it instead of waiting for the
|
||||||
|
expiry date.
|
||||||
|
|
||||||
|
To do so, we still have to use a database. We can store a logged out user's
|
||||||
|
jti into Redis, and each time we log in, look up the cache to find if the
|
||||||
|
user is logged out. And set the cache's timeout to the expiry time of the
|
||||||
|
token, so that it is removed automatically.
|
||||||
|
|
||||||
|
It'd better to inject the dependency of Redis connection into the `Authn`
|
||||||
|
middleware so that it's simpler to test.
|
||||||
|
|
||||||
|
### 2024/10/15
|
||||||
|
|
||||||
|
Redis is integrated to keep a blacklist of logged out users. BTW `memcached`
|
||||||
|
is also interesting. In case later I want to switch to another key-value
|
||||||
|
storage, I have made an interface. It also helps for the test. I can even
|
||||||
|
just drop the redis and use a bare-hand native hashmap.
|
||||||
|
|
||||||
|
Quite a lot benefits. And then I realised that I have done "wrong" about
|
||||||
|
`sqlc`. I shouldn't have used the pgx driver, instead the `database/sql`
|
||||||
|
driver is more universal, if I want to switch to sqlite or mysql later.
|
||||||
|
|
||||||
|
Well it's not about changing the technical solution every 3 days, but a
|
||||||
|
system than can survive those changes elegantly must be a robust system, with
|
||||||
|
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?
|
||||||
|
@ -76,7 +76,6 @@ paths:
|
|||||||
/session/create:
|
/session/create:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- user
|
|
||||||
- session
|
- session
|
||||||
description: Create a new session for a user
|
description: Create a new session for a user
|
||||||
requestBody:
|
requestBody:
|
||||||
@ -85,6 +84,41 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/SessionCreateRequest'
|
$ref: '#/components/schemas/SessionCreateRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
example: fakjshdflauhkjhsometokenakjsdhfaksj
|
||||||
|
'400':
|
||||||
|
description: Client side error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: AuthFailure
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "wrong email password."
|
||||||
|
'500':
|
||||||
|
description: Server side error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrResponse'
|
||||||
|
/session/delete:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- session
|
||||||
|
description: Delete an existing session for a user
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successful operation
|
description: Successful operation
|
||||||
@ -110,13 +144,15 @@ paths:
|
|||||||
example: AuthFailure
|
example: AuthFailure
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
example: "wrong email password."
|
example: "user not logged in."
|
||||||
'500':
|
'500':
|
||||||
description: Server side error
|
description: Server side error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrResponse'
|
$ref: '#/components/schemas/ErrResponse'
|
||||||
|
security:
|
||||||
|
- jwt: []
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
@ -161,3 +197,9 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
example: "Server internal error."
|
example: "Server internal error."
|
||||||
|
securitySchemes:
|
||||||
|
jwt:
|
||||||
|
name: Bearer authentication
|
||||||
|
type: http
|
||||||
|
bearerFormat: "JWT"
|
||||||
|
scheme: bearer
|
||||||
|
@ -3,6 +3,8 @@ dev-mode: true
|
|||||||
web:
|
web:
|
||||||
addr: :8000
|
addr: :8000
|
||||||
shutdown-timeout: 10
|
shutdown-timeout: 10
|
||||||
|
token-secret: nzMC12IJBMiiV2AAktTFpZP4BbGAf09lFPV_sATKcwI
|
||||||
|
token-expiry-time: 24h
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# DB host
|
# DB host
|
||||||
@ -14,6 +16,14 @@ db:
|
|||||||
# DB name
|
# DB name
|
||||||
database: howmuch
|
database: howmuch
|
||||||
|
|
||||||
|
max-open-conns: 100
|
||||||
|
max-idle-conns: 100
|
||||||
|
max-lifetime: 10s
|
||||||
|
|
||||||
|
cache:
|
||||||
|
host: 127.0.0.1:6379
|
||||||
|
password: ""
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: debug
|
level: debug
|
||||||
disalbe-caller: false
|
disalbe-caller: false
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres
|
image: postgres:alpine
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
@ -20,3 +20,15 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
deploy:
|
||||||
|
mode: replicated
|
||||||
|
replicas: 1
|
||||||
|
command: redis-server --save 20 1 --loglevel warning
|
||||||
|
volumes:
|
||||||
|
- ../../db_data_howmuch/redis/:/data
|
||||||
|
11
go.mod
11
go.mod
@ -3,41 +3,45 @@ module git.vinchent.xyz/vinchent/howmuch
|
|||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gin-contrib/cors v1.7.2
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gosuri/uitable v0.0.4
|
github.com/gosuri/uitable v0.0.4
|
||||||
github.com/jackc/pgx/v5 v5.7.1
|
github.com/jackc/pgx/v5 v5.7.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/redis/go-redis/v9 v9.6.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.19.0
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.9.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.27.0
|
golang.org/x/crypto v0.27.0
|
||||||
golang.org/x/net v0.25.0
|
golang.org/x/net v0.26.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/fatih/color v1.14.1 // indirect
|
github.com/fatih/color v1.14.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.22.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@ -62,6 +66,7 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/text v0.18.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
22
go.sum
22
go.sum
@ -1,9 +1,13 @@
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
@ -13,6 +17,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
|
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
|
||||||
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
@ -33,10 +39,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@ -91,6 +99,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||||
|
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
@ -143,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/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 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
@ -1,20 +1,115 @@
|
|||||||
|
// 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 controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware/authn"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Session interface {
|
type Session interface {
|
||||||
Create(*gin.Context)
|
Create(*gin.Context)
|
||||||
|
Delete(*gin.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionController struct{}
|
type SessionController struct {
|
||||||
|
userUsecase usecase.User
|
||||||
func NewSessionController() Session {
|
cache core.Cache
|
||||||
return &SessionController{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewSessionController(u usecase.User, cache core.Cache) Session {
|
||||||
|
return &SessionController{u, cache}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createParams struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a session for a user and returns a token
|
||||||
|
//
|
||||||
|
// 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) {
|
func (sc *SessionController) Create(ctx *gin.Context) {
|
||||||
log.CtxLog(ctx).DebugLog("session create")
|
var user model.UserExistRequest
|
||||||
|
|
||||||
|
if err := ctx.Bind(&user); err != nil {
|
||||||
|
log.ErrorLog("param error", "err", err)
|
||||||
|
core.WriteResponse(ctx, UserParamsErr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sc.userUsecase.Exist(ctx, &user)
|
||||||
|
if err != nil {
|
||||||
|
core.WriteResponse(ctx, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// user exists. Generate the token for the user
|
||||||
|
tokenString, err := token.Sign(user.Email)
|
||||||
|
if err != nil {
|
||||||
|
core.WriteResponse(ctx, errno.InternalServerErr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
core.WriteResponse(ctx, nil, Token{
|
||||||
|
Token: tokenString,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a session by putting the jwt token into the cache
|
||||||
|
func (sc *SessionController) Delete(ctx *gin.Context) {
|
||||||
|
tk, err := token.ParseRequest(ctx)
|
||||||
|
if err != nil || tk == nil {
|
||||||
|
// Unlikely
|
||||||
|
core.WriteResponse(ctx, authn.ErrTokenInvalid, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exp := time.Until(tk.Expiry)
|
||||||
|
key := fmt.Sprintf("jwt:%s", tk.Identity)
|
||||||
|
|
||||||
|
log.DebugLog("session delete", "key", key, "exp", exp.String())
|
||||||
|
err = sc.cache.Set(ctx, key, tk.Raw, exp)
|
||||||
|
if err != nil {
|
||||||
|
// unexpected
|
||||||
|
log.ErrorLog("error writing logged out jwt into cache", "err", err)
|
||||||
|
core.WriteResponse(ctx, errno.InternalServerErr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
core.WriteResponse(ctx, nil, "logged out")
|
||||||
}
|
}
|
||||||
|
190
internal/howmuch/adapter/controller/session_test.go
Normal file
190
internal/howmuch/adapter/controller/session_test.go
Normal 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 controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller/usecasemock"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware/authn"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// {{{ Test Cache
|
||||||
|
|
||||||
|
type testCache struct {
|
||||||
|
kvMap map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testCache) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
val, ok := c.kvMap[key]
|
||||||
|
if ok {
|
||||||
|
return val.(string), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testCache) Set(
|
||||||
|
ctx context.Context,
|
||||||
|
key string,
|
||||||
|
value interface{},
|
||||||
|
expiration time.Duration,
|
||||||
|
) error {
|
||||||
|
c.kvMap[key] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testCache) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
func TestSessionCreate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
User createParams
|
||||||
|
Errno *errno.Errno
|
||||||
|
}{
|
||||||
|
{"registered user", createParams{
|
||||||
|
Email: "correct@correct.com",
|
||||||
|
Password: "strong password",
|
||||||
|
}, errno.OK},
|
||||||
|
{"unregistered user", createParams{
|
||||||
|
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)
|
||||||
|
|
||||||
|
for _, tst := range tests {
|
||||||
|
t.Run(tst.Name, func(t *testing.T) {
|
||||||
|
testUserUsecase := usecasemock.NewtestUserUsecase()
|
||||||
|
sessionController := NewSessionController(testUserUsecase, nil)
|
||||||
|
r := gin.New()
|
||||||
|
r.POST(
|
||||||
|
"/session/create",
|
||||||
|
func(ctx *gin.Context) { sessionController.Create(ctx) },
|
||||||
|
)
|
||||||
|
user, _ := json.Marshal(tst.User)
|
||||||
|
res := test.PerformRequest(t, r, "POST", "/session/create", bytes.NewReader(user),
|
||||||
|
test.Header{
|
||||||
|
Key: "content-type",
|
||||||
|
Value: "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, tst.Errno.HTTP, res.Result().StatusCode, res.Body)
|
||||||
|
|
||||||
|
if tst.Errno.HTTP != http.StatusOK {
|
||||||
|
var got errno.Errno
|
||||||
|
err := json.NewDecoder(res.Result().Body).Decode(&got)
|
||||||
|
// XXX: the http status is not in the json. So it must be reset back the the struct
|
||||||
|
got.HTTP = res.Result().StatusCode
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tst.Errno, &got)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var got Token
|
||||||
|
err := json.NewDecoder(res.Result().Body).Decode(&got)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tkResp, err := token.Parse(got.Token)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tst.User.Email, tkResp.Identity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionDelete(t *testing.T) {
|
||||||
|
testUserUsecase := usecasemock.NewtestUserUsecase()
|
||||||
|
kvMap := make(map[string]interface{}, 1)
|
||||||
|
tc := &testCache{kvMap: kvMap}
|
||||||
|
sessionController := NewSessionController(testUserUsecase, tc)
|
||||||
|
r := gin.New()
|
||||||
|
session := r.Group("/session")
|
||||||
|
{
|
||||||
|
session.POST("/create", func(ctx *gin.Context) { sessionController.Create(ctx) })
|
||||||
|
session.Use(authn.Authn(tc))
|
||||||
|
session.POST("/delete", func(ctx *gin.Context) { sessionController.Delete(ctx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
params := createParams{
|
||||||
|
Email: "correct@correct.com",
|
||||||
|
Password: "strong password",
|
||||||
|
}
|
||||||
|
user, _ := json.Marshal(params)
|
||||||
|
res := test.PerformRequest(t, r, "POST", "/session/create", bytes.NewReader(user),
|
||||||
|
test.Header{
|
||||||
|
Key: "content-type",
|
||||||
|
Value: "application/json",
|
||||||
|
})
|
||||||
|
|
||||||
|
var tk Token
|
||||||
|
_ = json.NewDecoder(res.Result().Body).Decode(&tk)
|
||||||
|
tkResp, _ := token.Parse(tk.Token)
|
||||||
|
|
||||||
|
// Log out
|
||||||
|
res = test.PerformRequest(t, r, "POST", "/session/delete", nil,
|
||||||
|
test.Header{
|
||||||
|
Key: "Authorization",
|
||||||
|
Value: fmt.Sprintf("Bearer %s", tkResp.Raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
var loggedOut string
|
||||||
|
err := json.NewDecoder(res.Result().Body).Decode(&loggedOut)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "logged out", loggedOut)
|
||||||
|
|
||||||
|
// Try to access the handler with the old token
|
||||||
|
res = test.PerformRequest(t, r, "POST", "/session/delete", nil,
|
||||||
|
test.Header{
|
||||||
|
Key: "Authorization",
|
||||||
|
Value: fmt.Sprintf("Bearer %s", tkResp.Raw),
|
||||||
|
})
|
||||||
|
|
||||||
|
var unauth errno.Errno
|
||||||
|
err = json.NewDecoder(res.Result().Body).Decode(&unauth)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
unauth.HTTP = res.Result().StatusCode
|
||||||
|
assert.Equal(t, *authn.ErrLoggedOut, unauth)
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
// 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 usecasemock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testUserUsecase struct{}
|
||||||
|
|
||||||
|
func NewtestUserUsecase() usecase.User {
|
||||||
|
return &testUserUsecase{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*testUserUsecase) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
u *model.UserCreateRequest,
|
||||||
|
) (*model.UserInfoResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistRequest) error {
|
||||||
|
switch u.Email {
|
||||||
|
case "a@b.c":
|
||||||
|
if u.Password == "strong password" {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return usecase.UserWrongPassword
|
||||||
|
}
|
||||||
|
case "unregistered@error.com":
|
||||||
|
return usecase.UserNotExist
|
||||||
|
}
|
||||||
|
// Should never reach here
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*testUserUsecase) GetUserBaseResponseByID(
|
||||||
|
ctx context.Context,
|
||||||
|
userID int,
|
||||||
|
) (*model.UserBaseResponse, error) {
|
||||||
|
// TODO:
|
||||||
|
return nil, nil
|
||||||
|
}
|
@ -29,7 +29,6 @@ import (
|
|||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -58,22 +57,14 @@ func NewUserController(us usecase.User) User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) Create(ctx core.Context) {
|
func (uc *UserController) Create(ctx core.Context) {
|
||||||
var params model.User
|
var userRequest model.UserCreateRequest
|
||||||
|
|
||||||
if err := ctx.Bind(¶ms); err != nil {
|
if err := ctx.Bind(&userRequest); err != nil {
|
||||||
core.WriteResponse(ctx, UserParamsErr, nil)
|
core.WriteResponse(ctx, UserParamsErr, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := govalidator.ValidateStruct(params)
|
_, err := uc.userUsecase.Create(ctx, &userRequest)
|
||||||
if err != nil {
|
|
||||||
errno := UserParamsErr
|
|
||||||
errno.Message = err.Error()
|
|
||||||
core.WriteResponse(ctx, errno, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = uc.userUsecase.Create(ctx, ¶ms)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
core.WriteResponse(ctx, err, nil)
|
core.WriteResponse(ctx, err, nil)
|
||||||
return
|
return
|
||||||
|
@ -24,17 +24,21 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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/howmuch/usecase/repo"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type dbRepository struct {
|
type dbRepository struct {
|
||||||
db *pgx.Conn
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDBRepository(db *pgx.Conn) repo.DBRepository {
|
const queryTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func NewDBRepository(db *sql.DB) repo.DBRepository {
|
||||||
return &dbRepository{
|
return &dbRepository{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@ -46,23 +50,31 @@ func (dr *dbRepository) Transaction(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
txFunc func(txCtx context.Context, tx interface{}) (interface{}, error),
|
txFunc func(txCtx context.Context, tx interface{}) (interface{}, error),
|
||||||
) (interface{}, error) {
|
) (interface{}, error) {
|
||||||
tx, err := dr.db.Begin(ctx)
|
tx, err := dr.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if p := recover(); p != nil {
|
if p := recover(); p != nil {
|
||||||
tx.Rollback(ctx)
|
tx.Rollback()
|
||||||
log.PanicLog("transaction panicked!")
|
log.PanicLog("transaction panicked!")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
tx.Rollback(ctx)
|
tx.Rollback()
|
||||||
log.ErrorLog("transaction failed!", "err", err)
|
log.ErrorLog("transaction failed!", "err", err)
|
||||||
} else {
|
} else {
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
data, err := txFunc(ctx, tx)
|
data, err := txFunc(ctx, tx)
|
||||||
return data, err
|
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
|
||||||
|
}
|
||||||
|
276
internal/howmuch/adapter/repo/event.go
Normal file
276
internal/howmuch/adapter/repo/event.go
Normal 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
|
||||||
|
}
|
122
internal/howmuch/adapter/repo/event_test.go
Normal file
122
internal/howmuch/adapter/repo/event_test.go
Normal 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)
|
||||||
|
}
|
267
internal/howmuch/adapter/repo/expense.go
Normal file
267
internal/howmuch/adapter/repo/expense.go
Normal 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
|
||||||
|
}
|
96
internal/howmuch/adapter/repo/expense_test.go
Normal file
96
internal/howmuch/adapter/repo/expense_test.go
Normal 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)
|
||||||
|
}
|
@ -6,15 +6,14 @@ package sqlc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type DBTX interface {
|
type DBTX interface {
|
||||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db DBTX) *Queries {
|
func New(db DBTX) *Queries {
|
||||||
@ -25,7 +24,7 @@ type Queries struct {
|
|||||||
db DBTX
|
db DBTX
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||||
return &Queries{
|
return &Queries{
|
||||||
db: tx,
|
db: tx,
|
||||||
}
|
}
|
||||||
|
64
internal/howmuch/adapter/repo/sqlc/event.sql
Normal file
64
internal/howmuch/adapter/repo/sqlc/event.sql
Normal 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;
|
||||||
|
|
||||||
|
|
197
internal/howmuch/adapter/repo/sqlc/event.sql.go
Normal file
197
internal/howmuch/adapter/repo/sqlc/event.sql.go
Normal 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
|
||||||
|
}
|
64
internal/howmuch/adapter/repo/sqlc/expense.sql
Normal file
64
internal/howmuch/adapter/repo/sqlc/expense.sql
Normal 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;
|
223
internal/howmuch/adapter/repo/sqlc/expense.sql.go
Normal file
223
internal/howmuch/adapter/repo/sqlc/expense.sql.go
Normal 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
|
||||||
|
}
|
@ -5,7 +5,8 @@
|
|||||||
package sqlc
|
package sqlc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"database/sql"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
@ -15,12 +16,54 @@ type Admin struct {
|
|||||||
AccessLevel int32
|
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 {
|
type User struct {
|
||||||
ID int32
|
ID int32
|
||||||
Email string
|
Email string
|
||||||
FirstName string
|
FirstName string
|
||||||
LastName string
|
LastName string
|
||||||
Password string
|
Password string
|
||||||
CreatedAt pgtype.Timestamp
|
CreatedAt time.Time
|
||||||
UpdatedAt pgtype.Timestamp
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
10
internal/howmuch/adapter/repo/sqlc/participation.sql
Normal file
10
internal/howmuch/adapter/repo/sqlc/participation.sql
Normal 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;
|
72
internal/howmuch/adapter/repo/sqlc/participation.sql.go
Normal file
72
internal/howmuch/adapter/repo/sqlc/participation.sql.go
Normal 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
|
||||||
|
}
|
30
internal/howmuch/adapter/repo/sqlc/querier.go
Normal file
30
internal/howmuch/adapter/repo/sqlc/querier.go
Normal 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)
|
5
internal/howmuch/adapter/repo/sqlc/transaction.sql
Normal file
5
internal/howmuch/adapter/repo/sqlc/transaction.sql
Normal 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 *;
|
41
internal/howmuch/adapter/repo/sqlc/transaction.sql.go
Normal file
41
internal/howmuch/adapter/repo/sqlc/transaction.sql.go
Normal 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
|
||||||
|
}
|
@ -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
|
-- name: InsertUser :one
|
||||||
INSERT INTO "user" (
|
INSERT INTO "user" (
|
||||||
email, first_name, last_name, password, created_at, updated_at
|
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
|
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||||
FROM "user"
|
FROM "user"
|
||||||
WHERE email = $1;
|
WHERE email = $1;
|
||||||
|
|
||||||
|
-- name: GetUserByID :one
|
||||||
|
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||||
|
FROM "user"
|
||||||
|
WHERE id = $1;
|
||||||
|
@ -7,8 +7,7 @@ package sqlc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
@ -18,7 +17,28 @@ SELECT id, email, first_name, last_name, password, created_at, updated_at
|
|||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.Password,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
|
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||||
|
FROM "user"
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@ -33,7 +53,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
|
|
||||||
INSERT INTO "user" (
|
INSERT INTO "user" (
|
||||||
email, first_name, last_name, password, created_at, updated_at
|
email, first_name, last_name, password, created_at, updated_at
|
||||||
) VALUES ( $1, $2, $3, $4, $5, $6 )
|
) VALUES ( $1, $2, $3, $4, $5, $6 )
|
||||||
@ -45,33 +64,12 @@ type InsertUserParams struct {
|
|||||||
FirstName string
|
FirstName string
|
||||||
LastName string
|
LastName string
|
||||||
Password string
|
Password string
|
||||||
CreatedAt pgtype.Timestamp
|
CreatedAt time.Time
|
||||||
UpdatedAt pgtype.Timestamp
|
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) {
|
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
||||||
row := q.db.QueryRow(ctx, insertUser,
|
row := q.db.QueryRowContext(ctx, insertUser,
|
||||||
arg.Email,
|
arg.Email,
|
||||||
arg.FirstName,
|
arg.FirstName,
|
||||||
arg.LastName,
|
arg.LastName,
|
||||||
|
@ -24,87 +24,110 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
|
"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/model"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type userRepository struct {
|
type userRepository struct {
|
||||||
db *pgx.Conn
|
queries *sqlc.Queries
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertTimeout = 1 * time.Second
|
func NewUserRepository(db *sql.DB) repo.UserRepository {
|
||||||
|
|
||||||
func NewUserRepository(db *pgx.Conn) repo.UserRepository {
|
|
||||||
return &userRepository{
|
return &userRepository{
|
||||||
db: db,
|
queries: sqlc.New(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create
|
// Create
|
||||||
func (ur *userRepository) Create(
|
func (u *userRepository) Create(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
transaction interface{},
|
userEntity *model.UserEntity,
|
||||||
u *model.User,
|
tx any,
|
||||||
) (*model.User, error) {
|
) (*model.UserEntity, error) {
|
||||||
timeoutCtx, cancel := context.WithTimeout(ctx, insertTimeout)
|
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
args := sqlc.InsertUserParams{
|
queries := getQueries(u.queries, tx)
|
||||||
Email: u.Email,
|
|
||||||
FirstName: u.FirstName,
|
|
||||||
LastName: u.LastName,
|
|
||||||
Password: u.Password,
|
|
||||||
CreatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
|
|
||||||
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, ok := transaction.(pgx.Tx)
|
userDB, err := queries.InsertUser(timeoutCtx, sqlc.InsertUserParams{
|
||||||
if !ok {
|
Email: userEntity.Email,
|
||||||
return nil, errors.New("transaction is not a pgx.Tx")
|
FirstName: userEntity.FirstName,
|
||||||
}
|
LastName: userEntity.LastName,
|
||||||
|
Password: userEntity.Password,
|
||||||
queries := sqlc.New(tx)
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
userDB, err := queries.InsertUser(timeoutCtx, args)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.User{
|
return &model.UserEntity{
|
||||||
ID: int(userDB.ID),
|
ID: int(userDB.ID),
|
||||||
Email: userDB.Email,
|
Email: userDB.Email,
|
||||||
FirstName: userDB.FirstName,
|
FirstName: userDB.FirstName,
|
||||||
LastName: userDB.LastName,
|
LastName: userDB.LastName,
|
||||||
Password: userDB.Password,
|
Password: userDB.Password,
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt,
|
||||||
UpdatedAt: userDB.CreatedAt.Time,
|
UpdatedAt: userDB.CreatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByEmail if not found, return nil for user but not error.
|
// GetByEmail if not found, return nil for user but not error.
|
||||||
func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
|
func (u *userRepository) GetByEmail(
|
||||||
queries := sqlc.New(ur.db)
|
ctx context.Context,
|
||||||
userDB, err := queries.GetUserByEmail(ctx, email)
|
email string,
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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
|
// No query error, but user not found
|
||||||
return nil, nil
|
return nil, nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.User{
|
return &model.UserEntity{
|
||||||
ID: int(userDB.ID),
|
ID: int(userDB.ID),
|
||||||
Email: userDB.Email,
|
Email: userDB.Email,
|
||||||
FirstName: userDB.FirstName,
|
FirstName: userDB.FirstName,
|
||||||
LastName: userDB.LastName,
|
LastName: userDB.LastName,
|
||||||
Password: userDB.Password,
|
Password: userDB.Password,
|
||||||
CreatedAt: userDB.CreatedAt.Time,
|
CreatedAt: userDB.CreatedAt,
|
||||||
UpdatedAt: userDB.CreatedAt.Time,
|
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,
|
||||||
|
LastName: userDB.LastName,
|
||||||
|
Password: userDB.Password,
|
||||||
|
CreatedAt: userDB.CreatedAt,
|
||||||
|
UpdatedAt: userDB.CreatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
@ -35,9 +35,10 @@ import (
|
|||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/infra/router"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/infra/router"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/registry"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/registry"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/pkg/version/verflag"
|
"git.vinchent.xyz/vinchent/howmuch/pkg/version/verflag"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
@ -115,22 +116,35 @@ func run() error {
|
|||||||
viper.GetString("db.password"),
|
viper.GetString("db.password"),
|
||||||
viper.GetString("db.sslmode"),
|
viper.GetString("db.sslmode"),
|
||||||
)
|
)
|
||||||
dbConf, err := pgx.ParseConfig(dbConfString)
|
// TODO: viper conf should be parsed into a struct directly
|
||||||
if err != nil {
|
dbExtraConf := &datastore.DbExtraConf{
|
||||||
log.FatalLog("DB connection config failure", "err", err, "cfg string", dbConfString)
|
MaxOpenConns: viper.GetInt("db.max-open-conns"),
|
||||||
|
MaxIdleConns: viper.GetInt("db.max-idle-conns"),
|
||||||
|
MaxLifetime: viper.GetDuration("db.max-lifetime"),
|
||||||
}
|
}
|
||||||
dbConn, err := datastore.NewDB(dbConf)
|
dbConn := datastore.NewDB(dbConfString, dbExtraConf)
|
||||||
if err != nil {
|
if dbConn == nil {
|
||||||
log.FatalLog("DB connection failure", "err", err)
|
log.FatalLog("DB connection failure")
|
||||||
}
|
}
|
||||||
defer dbConn.Close(context.Background())
|
defer dbConn.Close()
|
||||||
|
|
||||||
|
// Init Cache
|
||||||
|
cache := datastore.NewCache(&redis.Options{
|
||||||
|
Addr: viper.GetString("cache.host"),
|
||||||
|
Password: viper.GetString("cache.password"),
|
||||||
|
DB: 0,
|
||||||
|
})
|
||||||
|
defer cache.Close()
|
||||||
|
|
||||||
|
// Init token
|
||||||
|
token.Init(viper.GetString("web.token-secret"), viper.GetDuration("web.token-expiry-time"))
|
||||||
|
|
||||||
// Register the core service
|
// Register the core service
|
||||||
r := registry.NewRegistry(dbConn)
|
r := registry.NewRegistry(dbConn, cache)
|
||||||
|
|
||||||
engine := gin.Default()
|
engine := gin.Default()
|
||||||
|
|
||||||
engine = router.Routes(engine, r.NewAppController())
|
engine = router.Routes(engine, r.NewAppController(), cache)
|
||||||
|
|
||||||
server := http.Server{
|
server := http.Server{
|
||||||
Addr: viper.GetString("web.addr"),
|
Addr: viper.GetString("web.addr"),
|
||||||
|
66
internal/howmuch/infra/datastore/cache.go
Normal file
66
internal/howmuch/infra/datastore/cache.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// 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 datastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RedisCache struct {
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCache(opt interface{}) core.Cache {
|
||||||
|
redisOpt := opt.(*redis.Options)
|
||||||
|
return &RedisCache{redis.NewClient(redisOpt)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedisCache) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
val, err := c.redis.Get(ctx, key).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
log.DebugLog("redis key not found", "key", key)
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
log.DebugLog("redis cache get error", "err", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedisCache) Set(
|
||||||
|
ctx context.Context,
|
||||||
|
key string,
|
||||||
|
value interface{},
|
||||||
|
expiration time.Duration,
|
||||||
|
) error {
|
||||||
|
return c.redis.Set(ctx, key, value, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedisCache) Close() error {
|
||||||
|
return c.redis.Close()
|
||||||
|
}
|
@ -23,22 +23,65 @@
|
|||||||
package datastore
|
package datastore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
|
_ "github.com/jackc/pgx/v5"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var counts int
|
||||||
|
|
||||||
|
type DbExtraConf struct {
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
MaxLifetime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
// NewDB creates a new database for the application
|
// NewDB creates a new database for the application
|
||||||
func NewDB(connConfig *pgx.ConnConfig) (*pgx.Conn, error) {
|
func NewDB(dsn string, opts interface{}) *sql.DB {
|
||||||
conn, err := pgx.ConnectConfig(context.Background(), connConfig)
|
var db *sql.DB
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for {
|
||||||
|
db, err = openDB(dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.WarnLog("postgres not ready", "err", err)
|
||||||
|
counts++
|
||||||
|
} else {
|
||||||
|
log.InfoLog("connected to postgres")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts > 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.InfoLog("retry in 2 seconds")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extraConf, ok := opts.(DbExtraConf)
|
||||||
|
if ok {
|
||||||
|
db.SetMaxOpenConns(extraConf.MaxOpenConns)
|
||||||
|
db.SetMaxIdleConns(extraConf.MaxIdleConns)
|
||||||
|
db.SetConnMaxLifetime(extraConf.MaxLifetime)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dsn string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping test the conn
|
if err := db.Ping(); err != nil {
|
||||||
if err = conn.Ping(context.Background()); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return db, nil
|
||||||
return conn, err
|
|
||||||
}
|
}
|
||||||
|
@ -23,15 +23,25 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware/authn"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
|
// Routes can take some options to init middlewares.
|
||||||
|
// - Cache
|
||||||
|
func Routes(engine *gin.Engine, c controller.AppController, opt ...interface{}) *gin.Engine {
|
||||||
|
cache, ok := opt[0].(core.Cache)
|
||||||
|
if !ok {
|
||||||
|
panic("the first option must be a cache driver")
|
||||||
|
}
|
||||||
|
|
||||||
// Middlewares
|
// Middlewares
|
||||||
// Cors
|
// Cors
|
||||||
corsCfg := cors.DefaultConfig()
|
corsCfg := cors.DefaultConfig()
|
||||||
@ -53,9 +63,21 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
|
|||||||
userV1 := v1.Group("/user")
|
userV1 := v1.Group("/user")
|
||||||
{
|
{
|
||||||
userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) })
|
userV1.POST("/create", func(ctx *gin.Context) { c.User.Create(ctx) })
|
||||||
|
|
||||||
|
userV1.Use(authn.Authn(cache))
|
||||||
|
userV1.GET(
|
||||||
|
":id/info",
|
||||||
|
func(ctx *gin.Context) { ctx.JSON(http.StatusOK, "Hello world") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionV1 := v1.Group("/session")
|
||||||
|
{
|
||||||
|
sessionV1.POST("/create", func(ctx *gin.Context) { c.Session.Create(ctx) })
|
||||||
|
sessionV1.Use(authn.Authn(cache))
|
||||||
|
sessionV1.POST("/delete", func(ctx *gin.Context) { c.Session.Delete(ctx) })
|
||||||
}
|
}
|
||||||
|
|
||||||
v1.POST("/session/create", func(ctx *gin.Context) { c.Session.Create(ctx) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return engine
|
return engine
|
||||||
|
136
internal/howmuch/model/event.go
Normal file
136
internal/howmuch/model/event.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
// {{{ 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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
142
internal/howmuch/model/expense.go
Normal file
142
internal/howmuch/model/expense.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// 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"
|
||||||
|
|
||||||
|
// {{{ 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 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
66
internal/howmuch/model/money.go
Normal file
66
internal/howmuch/model/money.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
type Currency string
|
||||||
|
|
||||||
|
// TODO: may handle a more complexe logic with the exchange rate.
|
||||||
|
|
||||||
|
// XXX: Here we suppose that the currency is the same for every piece
|
||||||
|
// of money involved in the calculate.
|
||||||
|
|
||||||
|
const (
|
||||||
|
EUR Currency = "EUR"
|
||||||
|
USD Currency = "USD"
|
||||||
|
CNY Currency = "CNY"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Money struct {
|
||||||
|
Amount int `json:"amount" binding:"required,number"`
|
||||||
|
Currency Currency `json:"currency" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func MakeMoney(amount int, currency Currency) Money {
|
||||||
|
return Money{amount, currency}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Add(cur Currency, money ...Money) Money {
|
||||||
|
var sum Money
|
||||||
|
sum.Currency = cur
|
||||||
|
|
||||||
|
for _, m := range money {
|
||||||
|
sum.Amount += m.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
func Diff(cur Currency, money1 Money, money2 Money) Money {
|
||||||
|
var diff Money
|
||||||
|
|
||||||
|
diff.Currency = cur
|
||||||
|
|
||||||
|
diff.Amount = money1.Amount - money2.Amount
|
||||||
|
|
||||||
|
return diff
|
||||||
|
}
|
39
internal/howmuch/model/participation.go
Normal file
39
internal/howmuch/model/participation.go
Normal 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
|
||||||
|
}
|
48
internal/howmuch/model/transaction.go
Normal file
48
internal/howmuch/model/transaction.go
Normal 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
|
@ -24,13 +24,77 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// User model
|
// {{{ Request (from controller to service)
|
||||||
type User struct {
|
|
||||||
ID int `json:"id"`
|
type UserCreateRequest struct {
|
||||||
Email string `json:"email" valid:"email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
FirstName string `json:"first_name" valid:"required"`
|
FirstName string `json:"first_name" binding:"required"`
|
||||||
LastName string `json:"last_name" valid:"required"`
|
LastName string `json:"last_name" binding:"required"`
|
||||||
Password string `json:"password" valid:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserExistRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
// {{{ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
@ -23,8 +23,10 @@
|
|||||||
package registry
|
package registry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
||||||
"github.com/jackc/pgx/v5"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// registry is an implementation of Registry interface.
|
// registry is an implementation of Registry interface.
|
||||||
@ -32,7 +34,8 @@ import (
|
|||||||
// It might holds other drivers when the projects grows. For example
|
// It might holds other drivers when the projects grows. For example
|
||||||
// the object needed to connect to Redis or Kafka.
|
// the object needed to connect to Redis or Kafka.
|
||||||
type registry struct {
|
type registry struct {
|
||||||
db *pgx.Conn
|
db *sql.DB
|
||||||
|
cache core.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry returns a new app controller that will be used by main()/run()
|
// Registry returns a new app controller that will be used by main()/run()
|
||||||
@ -43,8 +46,8 @@ type Registry interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry returns a new Registry's implementation.
|
// NewRegistry returns a new Registry's implementation.
|
||||||
func NewRegistry(db *pgx.Conn) Registry {
|
func NewRegistry(db *sql.DB, cache core.Cache) Registry {
|
||||||
return ®istry{db: db}
|
return ®istry{db: db, cache: cache}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAppController creates a new AppController with controller struct for
|
// NewAppController creates a new AppController with controller struct for
|
||||||
|
@ -1,9 +1,35 @@
|
|||||||
|
// 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 registry
|
package registry
|
||||||
|
|
||||||
import "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
import (
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
// NewSessionController returns a session controller's implementation
|
// NewSessionController returns a session controller's implementation
|
||||||
func (r *registry) NewSessionController() controller.Session {
|
func (r *registry) NewSessionController() controller.Session {
|
||||||
// u := usecase.NewSessionUsecase(repo.NewSessionRepository(r.db), repo.NewDBRepository(r.db))
|
u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
|
||||||
return controller.NewSessionController()
|
return controller.NewSessionController(u, r.cache)
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,9 @@
|
|||||||
|
|
||||||
package repo
|
package repo
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
type DBRepository interface {
|
type DBRepository interface {
|
||||||
Transaction(
|
Transaction(
|
||||||
|
74
internal/howmuch/usecase/repo/event.go
Normal file
74
internal/howmuch/usecase/repo/event.go
Normal 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)
|
||||||
|
}
|
@ -29,6 +29,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error)
|
Create(
|
||||||
GetByEmail(ctx context.Context, email string) (*model.User, error)
|
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)
|
||||||
}
|
}
|
||||||
|
190
internal/howmuch/usecase/usecase/event.go
Normal file
190
internal/howmuch/usecase/usecase/event.go
Normal 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
|
||||||
|
}
|
@ -20,9 +20,11 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
package usecase
|
package repomock
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
type TestDBRepository struct{}
|
type TestDBRepository struct{}
|
||||||
|
|
@ -20,7 +20,7 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
package usecase
|
package repomock
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -36,9 +36,9 @@ type TestUserRepository struct{}
|
|||||||
|
|
||||||
func (tur *TestUserRepository) Create(
|
func (tur *TestUserRepository) Create(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
transaction interface{},
|
u *model.UserEntity,
|
||||||
u *model.User,
|
tx any,
|
||||||
) (*model.User, error) {
|
) (*model.UserEntity, error) {
|
||||||
user := *u
|
user := *u
|
||||||
|
|
||||||
user.ID = 123
|
user.ID = 123
|
||||||
@ -50,11 +50,15 @@ func (tur *TestUserRepository) Create(
|
|||||||
return &user, nil
|
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)
|
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
|
||||||
switch email {
|
switch email {
|
||||||
case "a@b.c":
|
case "a@b.c":
|
||||||
return &model.User{
|
return &model.UserEntity{
|
||||||
ID: 123,
|
ID: 123,
|
||||||
Email: "a@b.c",
|
Email: "a@b.c",
|
||||||
Password: string(hashedPwd),
|
Password: string(hashedPwd),
|
||||||
@ -67,3 +71,24 @@ func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*mo
|
|||||||
|
|
||||||
return nil, UserTestDummyErr
|
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
|
||||||
|
}
|
@ -60,8 +60,9 @@ type userUsecase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User interface {
|
type User interface {
|
||||||
Create(ctx context.Context, u *model.User) (*model.User, error)
|
Create(ctx context.Context, u *model.UserCreateRequest) (*model.UserInfoResponse, error)
|
||||||
Exist(ctx context.Context, u *model.User) (bool, 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 {
|
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.User) (*model.User, error) {
|
func (uuc *userUsecase) Create(
|
||||||
|
ctx context.Context,
|
||||||
|
u *model.UserCreateRequest,
|
||||||
|
) (*model.UserInfoResponse, error) {
|
||||||
// Hash the password
|
// Hash the password
|
||||||
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
|
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -82,7 +86,12 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User,
|
|||||||
data, err := uuc.dbRepo.Transaction(
|
data, err := uuc.dbRepo.Transaction(
|
||||||
ctx,
|
ctx,
|
||||||
func(txCtx context.Context, tx interface{}) (interface{}, error) {
|
func(txCtx context.Context, tx interface{}) (interface{}, error) {
|
||||||
u, err := uuc.userRepo.Create(txCtx, tx, u)
|
created, err := uuc.userRepo.Create(txCtx, &model.UserEntity{
|
||||||
|
Email: u.Email,
|
||||||
|
Password: u.Password,
|
||||||
|
FirstName: u.FirstName,
|
||||||
|
LastName: u.LastName,
|
||||||
|
}, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
|
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
|
||||||
if match {
|
if match {
|
||||||
@ -95,12 +104,12 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User,
|
|||||||
log.InfoLog(
|
log.InfoLog(
|
||||||
"created new user",
|
"created new user",
|
||||||
"email",
|
"email",
|
||||||
u.Email,
|
created.Email,
|
||||||
"name",
|
"name",
|
||||||
fmt.Sprintf("%s %s", u.FirstName, u.LastName),
|
fmt.Sprintf("%s %s", created.FirstName, created.LastName),
|
||||||
)
|
)
|
||||||
|
|
||||||
return u, err
|
return created, err
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -108,28 +117,57 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User,
|
|||||||
return nil, err
|
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
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uuc *userUsecase) Exist(ctx context.Context, u *model.User) (bool, error) {
|
func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistRequest) error {
|
||||||
got, err := uuc.userRepo.GetByEmail(ctx, u.Email)
|
got, err := uuc.userRepo.GetByEmail(ctx, u.Email, nil)
|
||||||
// Any query error?
|
// Any query error?
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// User exists?
|
// User exists?
|
||||||
if got == nil {
|
if got == nil {
|
||||||
return false, UserNotExist
|
return UserNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password correct?
|
// Password correct?
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(u.Password))
|
err = bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(u.Password))
|
||||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
return false, UserWrongPassword
|
return UserWrongPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
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
|
||||||
}
|
}
|
||||||
|
@ -27,31 +27,33 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase/repomock"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCreateUser(t *testing.T) {
|
func TestCreateUser(t *testing.T) {
|
||||||
t.Run("normal create", func(t *testing.T) {
|
t.Run("normal create", func(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
|
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||||
input := &model.User{
|
input := &model.UserCreateRequest{
|
||||||
Email: "a@b.c",
|
Email: "a@b.c",
|
||||||
FirstName: "James",
|
FirstName: "James",
|
||||||
LastName: "Bond",
|
LastName: "Bond",
|
||||||
Password: "verystrong",
|
Password: "verystrong",
|
||||||
}
|
}
|
||||||
want := input
|
want := &model.UserInfoResponse{
|
||||||
want.ID = 123
|
ID: 123,
|
||||||
|
}
|
||||||
|
|
||||||
got, err := userUsecase.Create(ctx, input)
|
got, err := userUsecase.Create(ctx, input)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want.ID, got.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("duplicate create", func(t *testing.T) {
|
t.Run("duplicate create", func(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
|
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||||
input := &model.User{
|
input := &model.UserCreateRequest{
|
||||||
Email: "duplicate@error.com",
|
Email: "duplicate@error.com",
|
||||||
FirstName: "James",
|
FirstName: "James",
|
||||||
LastName: "Bond",
|
LastName: "Bond",
|
||||||
@ -66,34 +68,32 @@ func TestCreateUser(t *testing.T) {
|
|||||||
func TestUserExist(t *testing.T) {
|
func TestUserExist(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
Name string
|
Name string
|
||||||
User *model.User
|
User *model.UserExistRequest
|
||||||
ExpErr error
|
ExpErr error
|
||||||
ExpRes bool
|
|
||||||
}{
|
}{
|
||||||
{"user exists", &model.User{
|
{"user exists", &model.UserExistRequest{
|
||||||
Email: "a@b.c",
|
Email: "a@b.c",
|
||||||
Password: "strongHashed",
|
Password: "strongHashed",
|
||||||
}, nil, true},
|
}, nil},
|
||||||
{"query error", &model.User{
|
{"query error", &model.UserExistRequest{
|
||||||
Email: "query@error.com",
|
Email: "query@error.com",
|
||||||
Password: "strongHashed",
|
Password: "strongHashed",
|
||||||
}, UserTestDummyErr, false},
|
}, repomock.UserTestDummyErr},
|
||||||
{"user doesn not exist", &model.User{
|
{"user doesn not exist", &model.UserExistRequest{
|
||||||
Email: "inexist@error.com",
|
Email: "inexist@error.com",
|
||||||
Password: "strongHashed",
|
Password: "strongHashed",
|
||||||
}, UserNotExist, false},
|
}, UserNotExist},
|
||||||
{"wrong password", &model.User{
|
{"wrong password", &model.UserExistRequest{
|
||||||
Email: "a@b.c",
|
Email: "a@b.c",
|
||||||
Password: "wrongHashed",
|
Password: "wrongHashed",
|
||||||
}, UserWrongPassword, false},
|
}, UserWrongPassword},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tst := range testCases {
|
for _, tst := range testCases {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userUsecase := NewUserUsecase(&TestUserRepository{}, &TestDBRepository{})
|
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||||
|
|
||||||
got, err := userUsecase.Exist(ctx, tst.User)
|
err := userUsecase.Exist(ctx, tst.User)
|
||||||
assert.ErrorIs(t, err, tst.ExpErr)
|
assert.ErrorIs(t, err, tst.ExpErr)
|
||||||
assert.Equal(t, tst.ExpRes, got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
34
internal/pkg/core/cache.go
Normal file
34
internal/pkg/core/cache.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// 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 core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cache interface {
|
||||||
|
Get(ctx context.Context, key string) (string, error)
|
||||||
|
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
Close() error
|
||||||
|
}
|
@ -27,7 +27,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
@ -110,8 +110,12 @@ func CtxLog(ctx core.Context) *zapLogger {
|
|||||||
func (z *zapLogger) CtxLog(ctx core.Context) *zapLogger {
|
func (z *zapLogger) CtxLog(ctx core.Context) *zapLogger {
|
||||||
zz := z.clone()
|
zz := z.clone()
|
||||||
|
|
||||||
if rid := ctx.GetHeader(middleware.XRequestID); rid != "" {
|
if rid := ctx.GetHeader(shared.XRequestID); rid != "" {
|
||||||
zz.z = zz.z.With(zap.Any(middleware.XRequestID, rid))
|
zz.z = zz.z.With(zap.Any(shared.XRequestID, rid))
|
||||||
|
}
|
||||||
|
|
||||||
|
if user := ctx.GetHeader(shared.XUserName); user != "" {
|
||||||
|
zz.z = zz.z.With(zap.Any(shared.XUserName, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
return zz
|
return zz
|
||||||
|
77
internal/pkg/middleware/authn/authn.go
Normal file
77
internal/pkg/middleware/authn/authn.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// 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 authn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTokenInvalid = &errno.Errno{
|
||||||
|
HTTP: http.StatusUnauthorized,
|
||||||
|
Code: errno.ErrorCode(errno.AuthFailureCode, "TokenInvalid"),
|
||||||
|
Message: "invalid token",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrLoggedOut = &errno.Errno{
|
||||||
|
HTTP: http.StatusUnauthorized,
|
||||||
|
Code: errno.ErrorCode(errno.AuthFailureCode, "LoggedOut"),
|
||||||
|
Message: "logged out, please log in",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authn authenticates a user's access by validating their token.
|
||||||
|
func Authn(cache core.Cache) gin.HandlerFunc {
|
||||||
|
return func(ctx *gin.Context) {
|
||||||
|
tk, err := token.ParseRequest(ctx)
|
||||||
|
if err != nil || tk == nil {
|
||||||
|
core.WriteResponse(ctx, ErrTokenInvalid, nil)
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("jwt:%s", tk.Identity)
|
||||||
|
|
||||||
|
val, err := cache.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
log.ErrorLog("cache get token", "err", err)
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val != "" {
|
||||||
|
// blacklist
|
||||||
|
core.WriteResponse(ctx, ErrLoggedOut, nil)
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header(shared.XUserName, tk.Identity)
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
}
|
117
internal/pkg/middleware/authn/authn_test.go
Normal file
117
internal/pkg/middleware/authn/authn_test.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// 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 authn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var loggedOutID string
|
||||||
|
|
||||||
|
type testCache struct{}
|
||||||
|
|
||||||
|
func (tc *testCache) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
loggedOutKey := fmt.Sprintf("jwt:%s", loggedOutID)
|
||||||
|
if key == loggedOutKey {
|
||||||
|
return "found", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testCache) Set(
|
||||||
|
ctx context.Context,
|
||||||
|
key string,
|
||||||
|
value interface{},
|
||||||
|
expiration time.Duration,
|
||||||
|
) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testCache) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthn(t *testing.T) {
|
||||||
|
token.Init("secret", 1*time.Second)
|
||||||
|
tk, _ := token.Sign("user")
|
||||||
|
|
||||||
|
tkParsed, _ := token.Parse(tk)
|
||||||
|
loggedOutID = tkParsed.Identity
|
||||||
|
|
||||||
|
cache := &testCache{}
|
||||||
|
|
||||||
|
t.Run("token found in cache", func(t *testing.T) {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(Authn(cache))
|
||||||
|
|
||||||
|
r.GET("/example", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
res := test.PerformRequest(
|
||||||
|
t,
|
||||||
|
r,
|
||||||
|
"GET",
|
||||||
|
"/example",
|
||||||
|
nil,
|
||||||
|
test.Header{Key: shared.XUserName, Value: "user"},
|
||||||
|
test.Header{Key: "Authorization", Value: fmt.Sprintf("Bearer %s", tk)},
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, res.Result().StatusCode, res.Body)
|
||||||
|
|
||||||
|
var err errno.Errno
|
||||||
|
json.NewDecoder(res.Result().Body).Decode(&err)
|
||||||
|
assert.Equal(t, "AuthFailure.LoggedOut", err.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token not found in cache", func(t *testing.T) {
|
||||||
|
newTk, _ := token.Sign("user2")
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(Authn(cache))
|
||||||
|
|
||||||
|
r.GET("/example", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
res := test.PerformRequest(
|
||||||
|
t,
|
||||||
|
r,
|
||||||
|
"GET",
|
||||||
|
"/example",
|
||||||
|
nil,
|
||||||
|
test.Header{Key: shared.XUserName, Value: "user2"},
|
||||||
|
test.Header{Key: "Authorization", Value: fmt.Sprintf("Bearer %s", newTk)},
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusOK, res.Result().StatusCode, res.Body)
|
||||||
|
})
|
||||||
|
}
|
@ -23,24 +23,23 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const XRequestID = "X-Request-Id"
|
|
||||||
|
|
||||||
func RequestID() gin.HandlerFunc {
|
func RequestID() gin.HandlerFunc {
|
||||||
return func(ctx *gin.Context) {
|
return func(ctx *gin.Context) {
|
||||||
var rid string
|
var rid string
|
||||||
|
|
||||||
if rid = ctx.GetHeader(XRequestID); rid != "" {
|
if rid = ctx.GetHeader(shared.XRequestID); rid != "" {
|
||||||
ctx.Request.Header.Add(XRequestID, rid)
|
ctx.Request.Header.Add(shared.XRequestID, rid)
|
||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
rid = uuid.NewString()
|
rid = uuid.NewString()
|
||||||
ctx.Request.Header.Add(XRequestID, rid)
|
ctx.Request.Header.Add(shared.XRequestID, rid)
|
||||||
ctx.Header(XRequestID, rid)
|
ctx.Header(shared.XRequestID, rid)
|
||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
|
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -39,12 +40,12 @@ func TestRequestID(t *testing.T) {
|
|||||||
wanted := "123"
|
wanted := "123"
|
||||||
|
|
||||||
r.GET("/example", func(c *gin.Context) {
|
r.GET("/example", func(c *gin.Context) {
|
||||||
got = c.GetHeader(XRequestID)
|
got = c.GetHeader(shared.XRequestID)
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.POST("/example", func(c *gin.Context) {
|
r.POST("/example", func(c *gin.Context) {
|
||||||
got = c.GetHeader(XRequestID)
|
got = c.GetHeader(shared.XRequestID)
|
||||||
c.String(http.StatusAccepted, "ok")
|
c.String(http.StatusAccepted, "ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -55,12 +56,12 @@ func TestRequestID(t *testing.T) {
|
|||||||
"GET",
|
"GET",
|
||||||
"/example?a=100",
|
"/example?a=100",
|
||||||
nil,
|
nil,
|
||||||
test.Header{Key: XRequestID, Value: wanted},
|
test.Header{Key: shared.XRequestID, Value: wanted},
|
||||||
)
|
)
|
||||||
assert.Equal(t, "123", got)
|
assert.Equal(t, "123", got)
|
||||||
|
|
||||||
res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
|
res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
|
||||||
assert.NotEqual(t, "", got)
|
assert.NotEqual(t, "", got)
|
||||||
assert.NoError(t, uuid.Validate(got))
|
assert.NoError(t, uuid.Validate(got))
|
||||||
assert.Equal(t, res.Header()[XRequestID][0], got)
|
assert.Equal(t, res.Header()[shared.XRequestID][0], got)
|
||||||
}
|
}
|
||||||
|
28
internal/pkg/shared/const.go
Normal file
28
internal/pkg/shared/const.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// 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 shared
|
||||||
|
|
||||||
|
const (
|
||||||
|
XRequestID = "X-Request-Id"
|
||||||
|
XUserName = "X-Username"
|
||||||
|
)
|
@ -1,3 +1,25 @@
|
|||||||
|
// 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 test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
117
internal/pkg/token/token.go
Normal file
117
internal/pkg/token/token.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
// 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 token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
secretKey string
|
||||||
|
expiryTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
IdentityKey string `json:"identity_key"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResp struct {
|
||||||
|
Raw string
|
||||||
|
Identity string
|
||||||
|
Expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
config Config
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrMissingHeader = errors.New("Authorization is needed in the header")
|
||||||
|
|
||||||
|
func Init(secretKey string, expiryTime time.Duration) {
|
||||||
|
once.Do(func() {
|
||||||
|
config.secretKey = secretKey
|
||||||
|
config.expiryTime = expiryTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sign(identityKey string) (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
|
identityKey,
|
||||||
|
jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.expiryTime)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return token.SignedString([]byte(config.secretKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(tokenString string) (*TokenResp, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(
|
||||||
|
tokenString,
|
||||||
|
&Claims{},
|
||||||
|
func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return []byte(config.secretKey), nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok {
|
||||||
|
return &TokenResp{
|
||||||
|
tokenString,
|
||||||
|
claims.IdentityKey,
|
||||||
|
claims.ExpiresAt.Time,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRequest(c *gin.Context) (*TokenResp, error) {
|
||||||
|
// NOTE: Authorization: Bearer sdkfjlsfjlskdfjlsjdflk...slkdfjlka
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
|
||||||
|
if len(header) == 0 {
|
||||||
|
return nil, ErrMissingHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the token
|
||||||
|
var t string
|
||||||
|
fmt.Sscanf(header, "Bearer %s", &t)
|
||||||
|
|
||||||
|
return Parse(t)
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE "event"
|
10
migrations/20241017215433_create_event_table.postgres.up.sql
Normal file
10
migrations/20241017215433_create_event_table.postgres.up.sql
Normal 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
|
||||||
|
);
|
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE participation;
|
@ -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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "event"
|
||||||
|
ADD "total_amount" integer NULL;
|
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "participation"
|
||||||
|
ADD CONSTRAINT unique_user_event UNIQUE ("user_id", "event_id");
|
@ -0,0 +1 @@
|
|||||||
|
|
@ -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;
|
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE "expense";
|
@ -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
|
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE transaction;
|
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "transaction"
|
||||||
|
ADD FOREIGN KEY ("expense_id") REFERENCES "expense" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
2
sqlc.yml
2
sqlc.yml
@ -28,4 +28,4 @@ sql:
|
|||||||
gen:
|
gen:
|
||||||
go:
|
go:
|
||||||
out: "internal/howmuch/adapter/repo/sqlc"
|
out: "internal/howmuch/adapter/repo/sqlc"
|
||||||
sql_package: "pgx/v5"
|
emit_interface: true
|
||||||
|
43
web/package-lock.json
generated
43
web/package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
"vue": "^3.4.29",
|
"vue": "^3.4.29",
|
||||||
"vue-router": "^4.3.3"
|
"vue-router": "^4.3.3"
|
||||||
},
|
},
|
||||||
@ -2299,8 +2300,17 @@
|
|||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
"dev": true
|
},
|
||||||
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@ -2497,7 +2507,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
},
|
},
|
||||||
@ -2710,7 +2719,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
@ -3256,6 +3264,25 @@
|
|||||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||||
@ -3276,7 +3303,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
@ -4041,7 +4067,6 @@
|
|||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@ -4050,7 +4075,6 @@
|
|||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
@ -4614,6 +4638,11 @@
|
|||||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/psl": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
"vue": "^3.4.29",
|
"vue": "^3.4.29",
|
||||||
"vue-router": "^4.3.3"
|
"vue-router": "^4.3.3"
|
||||||
},
|
},
|
||||||
|
@ -37,6 +37,8 @@ import HelloWorld from './components/HelloWorld.vue'
|
|||||||
<nav>
|
<nav>
|
||||||
<RouterLink to="/">Home</RouterLink>
|
<RouterLink to="/">Home</RouterLink>
|
||||||
<RouterLink to="/about">About</RouterLink>
|
<RouterLink to="/about">About</RouterLink>
|
||||||
|
<RouterLink to="/login">Login</RouterLink>
|
||||||
|
<RouterLink to="/Signup">Signup</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -40,6 +40,16 @@ const router = createRouter({
|
|||||||
// this generates a separate chunk (About.[hash].js) for this route
|
// this generates a separate chunk (About.[hash].js) for this route
|
||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/AboutView.vue')
|
component: () => import('../views/AboutView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('../views/LoginView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/signup',
|
||||||
|
name: 'signup',
|
||||||
|
component: () => import('../views/SignupView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
83
web/src/views/LoginView.vue
Normal file
83
web/src/views/LoginView.vue
Normal 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>
|
95
web/src/views/SignupView.vue
Normal file
95
web/src/views/SignupView.vue
Normal 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>
|
Reference in New Issue
Block a user