Compare commits
65 Commits
4cdfe753c8
...
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 | |||
addddb152a | |||
3e09afd4b0 | |||
c312b4e2c8 | |||
eee5084821 | |||
ce3076047a | |||
9b6282a101 | |||
a3c2ade9fb | |||
be7f57d5a1 | |||
e1ca08db3a | |||
7209bc9c70 | |||
d35b47c7e6 | |||
5379895c4c | |||
8e73dc5f0b | |||
cc505e5a74 | |||
8f184ba797 | |||
cb7a4bf5c5 | |||
c1173b4bcc | |||
b7697bc89b | |||
ba8570857d | |||
344485d082 | |||
7b8abf8e5c | |||
4546665461 | |||
2c1beb30f6 | |||
43a1d0509c | |||
b14b8788ab |
@ -7,7 +7,7 @@ tmp_dir = "tmp"
|
||||
bin = "./_output/howmuch"
|
||||
cmd = "make build"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output", "internal/howmuch/adapter/repo/sqlc"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
|
63
.gitea/workflows/demo.yaml
Normal file
63
.gitea/workflows/demo.yaml
Normal file
@ -0,0 +1,63 @@
|
||||
# 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: Build and test
|
||||
run-name: ${{ gitea.actor }} is building and testing the project!
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||
- name: List files in the repository
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '1.23.1'
|
||||
- run: go version
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@v4
|
||||
with:
|
||||
sqlc-version: '1.25.0'
|
||||
- run: sqlc version
|
||||
- name: Build backend
|
||||
run: make build
|
||||
- name: Test backend
|
||||
run: make test
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Build frontend
|
||||
run: make web-build
|
||||
- name: Test frontend
|
||||
run: make web-test
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
36
.gitignore
vendored
36
.gitignore
vendored
@ -24,4 +24,38 @@ go.work.sum
|
||||
|
||||
# Custom
|
||||
/_output
|
||||
/deployment/db_data
|
||||
/deployment/tmp
|
||||
/tmp/**
|
||||
|
||||
# Vue
|
||||
#
|
||||
# Logs
|
||||
web/logs
|
||||
web/*.log
|
||||
web/npm-debug.log*
|
||||
web/yarn-debug.log*
|
||||
web/yarn-error.log*
|
||||
web/pnpm-debug.log*
|
||||
web/lerna-debug.log*
|
||||
|
||||
web/node_modules
|
||||
web/.DS_Store
|
||||
web/dist
|
||||
web/dist-ssr
|
||||
web/coverage
|
||||
web/*.local
|
||||
|
||||
web/cypress/videos/
|
||||
web/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
web/.vscode/*
|
||||
web/!.vscode/extensions.json
|
||||
web/.idea
|
||||
web/*.suo
|
||||
web/*.ntvs*
|
||||
web/*.njsproj
|
||||
web/*.sln
|
||||
web/*.sw?
|
||||
|
||||
web/*.tsbuildinfo
|
||||
|
28
Makefile
28
Makefile
@ -29,12 +29,16 @@ GO_LDFLAGS += \
|
||||
# ==============================================================================
|
||||
.PHONY: all
|
||||
all: add-copyright format build
|
||||
|
||||
web: web-all
|
||||
# ==============================================================================
|
||||
|
||||
.PHONY: build
|
||||
build: tidy # build.
|
||||
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null
|
||||
build: tidy sqlc # build.
|
||||
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go
|
||||
|
||||
.PHONY: sqlc
|
||||
sqlc:
|
||||
@sqlc generate
|
||||
|
||||
.PHONY: format
|
||||
format: # format code.
|
||||
@ -42,7 +46,7 @@ format: # format code.
|
||||
|
||||
.PHONY: add-copyright
|
||||
add-copyright: # add license to file headers.
|
||||
@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc
|
||||
@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc,web,mock
|
||||
|
||||
.PHONY: swagger
|
||||
swagger: # Run swagger.
|
||||
@ -52,6 +56,22 @@ swagger: # Run swagger.
|
||||
tidy: # Handle packkages.
|
||||
@go mod tidy
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@go test ./...
|
||||
|
||||
.PHONY: clean
|
||||
clean: # Clean up.
|
||||
@-rm -vrf $(OUTPUT_DIR)
|
||||
|
||||
.PHONY: web-build
|
||||
web-build:
|
||||
$(MAKE) -C web build
|
||||
|
||||
.PHONY: web-test
|
||||
web-test:
|
||||
$(MAKE) -C web test
|
||||
|
||||
.PHONY: web-all
|
||||
web-all:
|
||||
$(MAKE) -C web all
|
||||
|
418
README.md
418
README.md
@ -13,6 +13,19 @@
|
||||
- [Version](#version)
|
||||
- [2024/10/03](#20241003)
|
||||
- [2024/10/04](#20241004)
|
||||
- [2024/10/06](#20241006)
|
||||
- [Workflow](#workflow)
|
||||
- [2024/10/07](#20241007)
|
||||
- [The choice of the front end framework](#the-choice-of-the-front-end-framework)
|
||||
- [2024/10/08](#20241008)
|
||||
- [2024/10/09](#20241009)
|
||||
- [2024/10/11](#20241011)
|
||||
- [2024/10/13](#20241013)
|
||||
- [2024/10/15](#20241015)
|
||||
- [2024/10/16](#20241016)
|
||||
- [2024/10/17](#20241017)
|
||||
- [2024/10/18](#20241018)
|
||||
- [2024/10/19](#20241019)
|
||||
<!--toc:end-->
|
||||
|
||||
A tricount like expense-sharing system written in Go
|
||||
@ -23,6 +36,8 @@ It is a personal project to learn go and relative technologies.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Project Diary
|
||||
|
||||
### 2024/09/30
|
||||
@ -46,11 +61,11 @@ Next I need to design the API.
|
||||
- add other users to that event
|
||||
- A user can only view their own events, but not the events of other users'
|
||||
- A user can add an expense to the event (reason, date, who payed how much,
|
||||
who benefited how much)
|
||||
who benefited how much)
|
||||
- Users in the event can edit or delete one entry
|
||||
- changes are sent to friends in the event
|
||||
- User can get the money they spent themselves and the money they must pay
|
||||
to each other
|
||||
to each other
|
||||
- User can also get the total amount or the histories.
|
||||
|
||||
That is what I thought of for now.
|
||||
@ -94,10 +109,10 @@ The execution of the program is then just a command like `howmuch run`.
|
||||
Moreover, in a distributed system, configs can be stored on `etcd`.
|
||||
|
||||
> [Kubernetes stores configuration data into etcd for service discovery and
|
||||
cluster management; etcd’s consistency is crucial for correctly scheduling
|
||||
and operating services. The Kubernetes API server persists cluster state
|
||||
into etcd. It uses etcd’s watch API to monitor the cluster and roll out
|
||||
critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
|
||||
> cluster management; etcd’s consistency is crucial for correctly scheduling
|
||||
> and operating services. The Kubernetes API server persists cluster state
|
||||
> into etcd. It uses etcd’s watch API to monitor the cluster and roll out
|
||||
> critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
|
||||
|
||||
#### Business logic
|
||||
|
||||
@ -105,8 +120,8 @@ critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
|
||||
- init DBs (Redis, SQL, Kafka, etc.)
|
||||
- init web service (http, https, gRPC, etc.)
|
||||
- start async tasks like `watch kube-apiserver`; pull data from third-party
|
||||
services; store, register `/metrics` and listen on some port; start kafka
|
||||
consumer queue, etc.
|
||||
services; store, register `/metrics` and listen on some port; start kafka
|
||||
consumer queue, etc.
|
||||
- Run specific business logic
|
||||
- Stop the program
|
||||
- others...
|
||||
@ -158,26 +173,26 @@ that has several layers:
|
||||
- Entities: the models of the product
|
||||
- Use cases: the core business rule
|
||||
- Interface Adapters: convert data-in to entities and convert data-out to
|
||||
output ports.
|
||||
output ports.
|
||||
- Frameworks and drivers: Web server, DB.
|
||||
|
||||
Based on this logic, we create the following directories:
|
||||
|
||||
- `model`: entities
|
||||
- `infra`: Provides the necessary functions to setup the infrastructure,
|
||||
especially the DB (output-port), but also the router (input-port). Once
|
||||
setup, we don't touch them anymore.
|
||||
especially the DB (output-port), but also the router (input-port). Once
|
||||
setup, we don't touch them anymore.
|
||||
- `registry`: Provides a register function for the main to register a service.
|
||||
It takes the pass to the output-port (ex.DBs) and gives back a pass
|
||||
(controller) to the input-port
|
||||
It takes the pass to the output-port (ex.DBs) and gives back a pass
|
||||
(controller) to the input-port
|
||||
- `adapter`: Controllers are one of the adapters, when they are called,
|
||||
they parse the user input and parse them into models and run the usecase
|
||||
rules. Then they send back the response(input-port). For the output-port
|
||||
part, the `repo` is the implementation of interfaces defined in `usecase/repo`.
|
||||
they parse the user input and parse them into models and run the usecase
|
||||
rules. Then they send back the response(input-port). For the output-port
|
||||
part, the `repo` is the implementation of interfaces defined in `usecase/repo`.
|
||||
- `usecase`: with the input of adapter, do what have to be done, and answer
|
||||
with the result. In the meantime, we may have to store things into DBs.
|
||||
Here we use the Repository model to decouple the implementation of the repo
|
||||
with the interface. Thus in `usecase/repo` we only define interfaces.
|
||||
with the result. In the meantime, we may have to store things into DBs.
|
||||
Here we use the Repository model to decouple the implementation of the repo
|
||||
with the interface. Thus in `usecase/repo` we only define interfaces.
|
||||
|
||||
Then it comes the real design for the app.
|
||||
|
||||
@ -198,9 +213,372 @@ type User struct {
|
||||
|
||||
Use Buffalo pop `Soda CLI` to create database migrations.
|
||||
|
||||
### 2024/10/07
|
||||
### 2024/10/06
|
||||
|
||||
Implement the architecture design for User entity.
|
||||
|
||||
Checked out OpenAPI, and found that it was not that simple at all. It needs
|
||||
a whole package of knowledge about the web development!
|
||||
|
||||
For the test-driven part,
|
||||
|
||||
- model layer: just model designs, **nothing to test**
|
||||
- infra: routes and db connections, it works when it works. Nothing to test.
|
||||
- registry: Just return some structs, no logic. **Not worth testing**
|
||||
- adapter:
|
||||
- input-port (controller) test: it is about testing parsing the input
|
||||
value, and the output results writing. The unit test of controller is to
|
||||
**make sure that they behave as defined in the API documentation**. To
|
||||
test, we have to mock the **business service**.
|
||||
- output-port (repo) test: it is about testing converting business model
|
||||
to database model and the interaction with the database. If we are going
|
||||
to test them, it's about simulating different type of database behaviour
|
||||
(success, timeout, etc.). To test, we have to mock the
|
||||
**database connection**.
|
||||
- usecase: This is the core part to test, it's about the core business.
|
||||
We provide the data input and we check the data output in a fake repository.
|
||||
|
||||
With this design, although it may seem overkill for this little project, fits
|
||||
perfectly well with the TDD method.
|
||||
|
||||
Concretely, I will do the TDD for my usecase level development, and for the
|
||||
rest, I just put unit tests aside for later.
|
||||
|
||||
#### Workflow
|
||||
|
||||
1. OAS Definition
|
||||
2. (Integration/Validation test)
|
||||
3. Usecase unit test cases
|
||||
4. Usecase development
|
||||
5. Refactor (2-3-4)
|
||||
6. Input-port/Output-port
|
||||
|
||||
That should be the correct workflow. But to save time, I will cut off the
|
||||
integration test part (the 2nd point).
|
||||
|
||||
### 2024/10/07
|
||||
|
||||
I rethought about the whole API design (even though I have only one yet). I
|
||||
have created `/signup` and `/login` without thinking too much, but in fact
|
||||
it is not quite _RESTful_.
|
||||
|
||||
**REST** is all about resources. While `/signup` and `/login` is quite
|
||||
comprehensible, thus service-oriented, they don't follow the REST philosophy,
|
||||
that is to say, **resource-oriented**.
|
||||
|
||||
If we rethink about `/signup`, what it does is to create a resource of `User`.
|
||||
Thus, for a backend API, it'd better be named as `User.Create`. But what
|
||||
about `/login`, it doesn't do anything about `User`. It would be strange to
|
||||
declare it as a User-relevant method.
|
||||
|
||||
Instead, what `/login` really does, is to **create a session**.
|
||||
In consequence, we have to create a new struct `Session` that can be created,
|
||||
deleted, or updated.
|
||||
|
||||
It might seem overkill, and in real life, even in the official Pet store
|
||||
example of OpenAPI, signup and login are under /user. But it just opened my
|
||||
mind and forces me to **think and design RESTfully**!
|
||||
|
||||
That being said, for the user side, we shall still have `/signup` and `/login`,
|
||||
because on the Front-end, we must be user-centered. We can even make this
|
||||
2 functions on the same page with the same endpoint `/login`. The user enter
|
||||
the email and the password, then clicks on `Login or Signup`. If the login
|
||||
is successful, then he is logged in. Otherwise, if the user doesn't exist
|
||||
yet, we open up 2 more inputs (first name and last name) for signup. They
|
||||
can just provide the extra information and click again on `Signup`.
|
||||
|
||||
That, again, being said, I am thinking about doing some Front-end stuff just
|
||||
to make the validation tests of the product simpler.
|
||||
|
||||
#### The choice of the front end framework
|
||||
|
||||
I have considered several choices.
|
||||
|
||||
If I didn't purposely make the backend code to provide a REST API, I might
|
||||
choose server-side-rendering with `templ + htmx`, or even `template+vanilla
|
||||
javascript`.
|
||||
|
||||
I can still write a rather static Go-frontend-server to serve HTMLs and call
|
||||
my Go backend. _And it might be a good idea if they communicate on Go native
|
||||
rpc._ It worth a try.
|
||||
|
||||
And I have moved on to `Svelte` which seems very simple by design and the
|
||||
whole compile thing makes it really charm. But this is mainly a Go project,
|
||||
to learn something new with a rather small community means potentially more
|
||||
investment. I can learn it later.
|
||||
|
||||
Among `Angular`, `React` and `Vue`, I prefer `Vue`, for several reasons.
|
||||
First, `Angular` is clearly overkill for this small demo project. Second,
|
||||
`React` is good but I personally like the way of Vue doing things. And I
|
||||
work with Vue at work, so I might have more technical help from my colleagues.
|
||||
|
||||
So the plan for this week is to have both the Front end part and Backend part
|
||||
working, just for user signup and login.
|
||||
|
||||
I would like to directly put this stuff on a CI-pipeline for tests and
|
||||
deployment, even I have barely nothing yet. It is always good to do this
|
||||
preparation stuff at the early stage of the project. So we can benefit from
|
||||
them all the way along.
|
||||
|
||||
Moreover, even I am not really finishing the project, it can still be
|
||||
something representable that I can show to a future interviewer.
|
||||
|
||||
### 2024/10/08
|
||||
|
||||
Gitea action setup ! 🎉🎉🎉
|
||||
|
||||
Next step is to run some check and build and test!
|
||||
|
||||
### 2024/10/09
|
||||
|
||||
No code for today neither. But I did some design for the user story and
|
||||
the database model design.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 2024/10/11
|
||||
|
||||
I spent 2 days learning some basic of Vue. Learning Vue takes time. There
|
||||
are a lot of concepts and it needs a lot of practice. Even though I may not
|
||||
need a professional level web page, I don't want to copy one module from this
|
||||
blog and another one from another tutorial. I might just put aside the
|
||||
front-end for now and concentrate on my backend Go app.
|
||||
|
||||
For now, I will just test my backend with `curl`.
|
||||
|
||||
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?
|
||||
|
@ -37,19 +37,20 @@ servers:
|
||||
- url: https:/localhost:8000/v1
|
||||
tags:
|
||||
- name: user
|
||||
- name: session
|
||||
|
||||
paths:
|
||||
/signup:
|
||||
/user/create:
|
||||
post:
|
||||
tags:
|
||||
- user
|
||||
description: Sign up as a new user
|
||||
description: Create a new user
|
||||
requestBody:
|
||||
description: Sign up
|
||||
description: Create a new user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSignUpRequest'
|
||||
$ref: '#/components/schemas/UserCreateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful operation
|
||||
@ -58,17 +59,104 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrResponse'
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: FailedOperation.UserExisted
|
||||
message:
|
||||
type: string
|
||||
example: "Email already existed."
|
||||
'500':
|
||||
description: Server side error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrResponse'
|
||||
/session/create:
|
||||
post:
|
||||
tags:
|
||||
- session
|
||||
description: Create a new session for a user
|
||||
requestBody:
|
||||
description: Create session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
'200':
|
||||
description: Successful operation
|
||||
headers:
|
||||
X-Expires-After:
|
||||
description: date in UTC when token expires
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'400':
|
||||
description: Client side error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: AuthFailure
|
||||
message:
|
||||
type: string
|
||||
example: "user not logged in."
|
||||
'500':
|
||||
description: Server side error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrResponse'
|
||||
security:
|
||||
- jwt: []
|
||||
|
||||
components:
|
||||
schemas:
|
||||
UserSignUpRequest:
|
||||
UserCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
@ -88,12 +176,30 @@ components:
|
||||
- fist_name
|
||||
- last_name
|
||||
- password
|
||||
SessionCreateRequest:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
example: bruce@wayne.com
|
||||
password:
|
||||
type: string
|
||||
example: verystrongpassword
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
ErrResponse:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: FailedOperation.UserAlreadyExists
|
||||
example: InternalError
|
||||
message:
|
||||
type: string
|
||||
example: "User already exists."
|
||||
example: "Server internal error."
|
||||
securitySchemes:
|
||||
jwt:
|
||||
name: Bearer authentication
|
||||
type: http
|
||||
bearerFormat: "JWT"
|
||||
scheme: bearer
|
||||
|
@ -3,6 +3,8 @@ dev-mode: true
|
||||
web:
|
||||
addr: :8000
|
||||
shutdown-timeout: 10
|
||||
token-secret: nzMC12IJBMiiV2AAktTFpZP4BbGAf09lFPV_sATKcwI
|
||||
token-expiry-time: 24h
|
||||
|
||||
db:
|
||||
# DB host
|
||||
@ -14,6 +16,14 @@ db:
|
||||
# DB name
|
||||
database: howmuch
|
||||
|
||||
max-open-conns: 100
|
||||
max-idle-conns: 100
|
||||
max-lifetime: 10s
|
||||
|
||||
cache:
|
||||
host: 127.0.0.1:6379
|
||||
password: ""
|
||||
|
||||
log:
|
||||
level: debug
|
||||
disalbe-caller: false
|
||||
|
@ -1,7 +1,7 @@
|
||||
services:
|
||||
|
||||
postgres:
|
||||
image: postgres
|
||||
image: postgres:alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
@ -20,3 +20,15 @@ services:
|
||||
restart: always
|
||||
ports:
|
||||
- 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
|
||||
|
7
docs/error_code.md
Normal file
7
docs/error_code.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Platform level error code design
|
||||
|
||||
- InternalError
|
||||
- InvalidParameter
|
||||
- AuthFailure
|
||||
- ResourceNotFound
|
||||
- FailedOperation
|
BIN
docs/howmuch.drawio.png
Normal file
BIN
docs/howmuch.drawio.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 358 KiB |
BIN
docs/howmuch_us1.drawio.png
Normal file
BIN
docs/howmuch_us1.drawio.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 116 KiB |
11
go.mod
11
go.mod
@ -6,29 +6,35 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
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/gosuri/uitable v0.0.4
|
||||
github.com/jackc/pgx/v5 v5.7.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/pflag v1.0.5
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/net v0.25.0
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/net v0.26.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // 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/iasm v0.2.0 // 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/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.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/google/go-cmp v0.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
@ -59,7 +65,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.27.0 // 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
|
||||
|
22
go.sum
22
go.sum
@ -1,7 +1,13 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
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/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/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
@ -11,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.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/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/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@ -31,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/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/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||
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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@ -84,9 +94,13 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
@ -139,8 +153,8 @@ golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -28,4 +28,6 @@ type AppController struct {
|
||||
User interface{ User }
|
||||
|
||||
Admin interface{ Admin }
|
||||
|
||||
Session interface{ Session }
|
||||
}
|
||||
|
115
internal/howmuch/adapter/controller/session.go
Normal file
115
internal/howmuch/adapter/controller/session.go
Normal file
@ -0,0 +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
|
||||
|
||||
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/middleware/authn"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Session interface {
|
||||
Create(*gin.Context)
|
||||
Delete(*gin.Context)
|
||||
}
|
||||
|
||||
type SessionController struct {
|
||||
userUsecase usecase.User
|
||||
cache core.Cache
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
@ -1,33 +1,3 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: user.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const insertUser = `-- name: InsertUser :one
|
||||
|
||||
INSERT INTO "user" (
|
||||
email, first_name, last_name, password, created_at, updated_at
|
||||
) VALUES ( $1, $2, $3, $4, $5, $6 )
|
||||
RETURNING id, email, first_name, last_name, password, created_at, updated_at
|
||||
`
|
||||
|
||||
type InsertUserParams struct {
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Password string
|
||||
CreatedAt pgtype.Timestamp
|
||||
UpdatedAt pgtype.Timestamp
|
||||
}
|
||||
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
|
||||
@ -49,24 +19,48 @@ type InsertUserParams struct {
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, insertUser,
|
||||
arg.Email,
|
||||
arg.FirstName,
|
||||
arg.LastName,
|
||||
arg.Password,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FirstName,
|
||||
&i.LastName,
|
||||
&i.Password,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
|
||||
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
|
||||
}
|
@ -25,33 +25,56 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// User is the user controller interface, it describes all the handlers
|
||||
// that need to be implemented for the /user endpoint
|
||||
type User interface {
|
||||
Signup(core.Context)
|
||||
UpdateInfo(core.Context)
|
||||
Login(core.Context)
|
||||
Logout(core.Context)
|
||||
ChangePassword(core.Context)
|
||||
Create(core.Context)
|
||||
UpdateInfo(*gin.Context)
|
||||
ChangePassword(*gin.Context)
|
||||
}
|
||||
|
||||
type UserController struct{}
|
||||
|
||||
func (uc *UserController) Signup(ctx core.Context) {
|
||||
ctx.JSON(http.StatusOK, "hello")
|
||||
type UserController struct {
|
||||
userUsecase usecase.User
|
||||
}
|
||||
|
||||
func (uc *UserController) UpdateInfo(ctx core.Context) {
|
||||
var UserParamsErr = &errno.Errno{
|
||||
HTTP: http.StatusBadRequest,
|
||||
Code: errno.ErrorCode(errno.InvalidParameterCode, "UserParamsErr"),
|
||||
Message: "user info is not correct",
|
||||
}
|
||||
|
||||
func (uc *UserController) Login(ctx core.Context) {
|
||||
func NewUserController(us usecase.User) User {
|
||||
return &UserController{
|
||||
userUsecase: us,
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *UserController) Logout(ctx core.Context) {
|
||||
func (uc *UserController) Create(ctx core.Context) {
|
||||
var userRequest model.UserCreateRequest
|
||||
|
||||
if err := ctx.Bind(&userRequest); err != nil {
|
||||
core.WriteResponse(ctx, UserParamsErr, nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := uc.userUsecase.Create(ctx, &userRequest)
|
||||
if err != nil {
|
||||
core.WriteResponse(ctx, err, nil)
|
||||
return
|
||||
}
|
||||
|
||||
core.WriteResponse(ctx, errno.OK, nil)
|
||||
}
|
||||
|
||||
func (uc *UserController) ChangePassword(ctx core.Context) {
|
||||
func (uc *UserController) UpdateInfo(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (uc *UserController) ChangePassword(ctx *gin.Context) {
|
||||
}
|
||||
|
80
internal/howmuch/adapter/repo/db.go
Normal file
80
internal/howmuch/adapter/repo/db.go
Normal file
@ -0,0 +1,80 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||
)
|
||||
|
||||
type dbRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const queryTimeout = 3 * time.Second
|
||||
|
||||
func NewDBRepository(db *sql.DB) repo.DBRepository {
|
||||
return &dbRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: Do I need rollback? in which cases?
|
||||
|
||||
func (dr *dbRepository) Transaction(
|
||||
ctx context.Context,
|
||||
txFunc func(txCtx context.Context, tx interface{}) (interface{}, error),
|
||||
) (interface{}, error) {
|
||||
tx, err := dr.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
tx.Rollback()
|
||||
log.PanicLog("transaction panicked!")
|
||||
} else if err != nil {
|
||||
tx.Rollback()
|
||||
log.ErrorLog("transaction failed!", "err", err)
|
||||
} else {
|
||||
err = tx.Commit()
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := txFunc(ctx, tx)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func getQueries(queries *sqlc.Queries, tx any) *sqlc.Queries {
|
||||
transaction, ok := tx.(*sql.Tx)
|
||||
if ok {
|
||||
return sqlc.New(transaction)
|
||||
}
|
||||
return queries
|
||||
}
|
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)
|
||||
}
|
31
internal/howmuch/adapter/repo/sqlc/db.go
Normal file
31
internal/howmuch/adapter/repo/sqlc/db.go
Normal file
@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
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
|
||||
}
|
69
internal/howmuch/adapter/repo/sqlc/models.go
Normal file
69
internal/howmuch/adapter/repo/sqlc/models.go
Normal file
@ -0,0 +1,69 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Admin struct {
|
||||
ID int32
|
||||
Email string
|
||||
Password string
|
||||
AccessLevel int32
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int32
|
||||
Name string
|
||||
Description sql.NullString
|
||||
DefaultCurrency string
|
||||
OwnerID int32
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
TotalAmount sql.NullInt32
|
||||
}
|
||||
|
||||
type Expense struct {
|
||||
ID int32
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Amount int32
|
||||
Currency string
|
||||
EventID int32
|
||||
Name sql.NullString
|
||||
Place sql.NullString
|
||||
}
|
||||
|
||||
type Participation struct {
|
||||
ID int32
|
||||
UserID int32
|
||||
EventID int32
|
||||
InvitedByUserID sql.NullInt32
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
ID int32
|
||||
ExpenseID int32
|
||||
UserID int32
|
||||
Amount int32
|
||||
Currency string
|
||||
IsIncome bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Password string
|
||||
CreatedAt time.Time
|
||||
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,27 +1,15 @@
|
||||
-- MIT License
|
||||
--
|
||||
-- Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
|
||||
--
|
||||
-- Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
-- of this software and associated documentation files (the "Software"), to deal
|
||||
-- in the Software without restriction, including without limitation the rights
|
||||
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
-- copies of the Software, and to permit persons to whom the Software is
|
||||
-- furnished to do so, subject to the following conditions:
|
||||
--
|
||||
-- The above copyright notice and this permission notice shall be included in all
|
||||
-- copies or substantial portions of the Software.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
-- SOFTWARE.
|
||||
|
||||
-- name: InsertUser :one
|
||||
INSERT INTO "user" (
|
||||
email, first_name, last_name, password, created_at, updated_at
|
||||
) VALUES ( $1, $2, $3, $4, $5, $6 )
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||
FROM "user"
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||
FROM "user"
|
||||
WHERE id = $1;
|
||||
|
91
internal/howmuch/adapter/repo/sqlc/user.sql.go
Normal file
91
internal/howmuch/adapter/repo/sqlc/user.sql.go
Normal file
@ -0,0 +1,91 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: user.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, email, first_name, last_name, password, created_at, updated_at
|
||||
FROM "user"
|
||||
WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
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
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FirstName,
|
||||
&i.LastName,
|
||||
&i.Password,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertUser = `-- name: InsertUser :one
|
||||
INSERT INTO "user" (
|
||||
email, first_name, last_name, password, created_at, updated_at
|
||||
) VALUES ( $1, $2, $3, $4, $5, $6 )
|
||||
RETURNING id, email, first_name, last_name, password, created_at, updated_at
|
||||
`
|
||||
|
||||
type InsertUserParams struct {
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Password string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertUser,
|
||||
arg.Email,
|
||||
arg.FirstName,
|
||||
arg.LastName,
|
||||
arg.Password,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.FirstName,
|
||||
&i.LastName,
|
||||
&i.Password,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
133
internal/howmuch/adapter/repo/user.go
Normal file
133
internal/howmuch/adapter/repo/user.go
Normal file
@ -0,0 +1,133 @@
|
||||
// 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"
|
||||
"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"
|
||||
)
|
||||
|
||||
type userRepository struct {
|
||||
queries *sqlc.Queries
|
||||
}
|
||||
|
||||
func NewUserRepository(db *sql.DB) repo.UserRepository {
|
||||
return &userRepository{
|
||||
queries: sqlc.New(db),
|
||||
}
|
||||
}
|
||||
|
||||
// Create
|
||||
func (u *userRepository) Create(
|
||||
ctx context.Context,
|
||||
userEntity *model.UserEntity,
|
||||
tx any,
|
||||
) (*model.UserEntity, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
queries := getQueries(u.queries, tx)
|
||||
|
||||
userDB, err := queries.InsertUser(timeoutCtx, sqlc.InsertUserParams{
|
||||
Email: userEntity.Email,
|
||||
FirstName: userEntity.FirstName,
|
||||
LastName: userEntity.LastName,
|
||||
Password: userEntity.Password,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// GetByEmail if not found, return nil for user but not error.
|
||||
func (u *userRepository) GetByEmail(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
tx any,
|
||||
) (*model.UserEntity, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
queries := getQueries(u.queries, tx)
|
||||
|
||||
userDB, err := queries.GetUserByEmail(timeoutCtx, email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// No query error, but user not found
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.UserEntity{
|
||||
ID: int(userDB.ID),
|
||||
Email: userDB.Email,
|
||||
FirstName: userDB.FirstName,
|
||||
LastName: userDB.LastName,
|
||||
Password: userDB.Password,
|
||||
CreatedAt: userDB.CreatedAt,
|
||||
UpdatedAt: userDB.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *userRepository) GetByID(ctx context.Context, id int, tx any) (*model.UserEntity, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
queries := getQueries(u.queries, tx)
|
||||
|
||||
userDB, err := queries.GetUserByID(timeoutCtx, int32(id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// No query error, but user not found
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.UserEntity{
|
||||
ID: int(userDB.ID),
|
||||
Email: userDB.Email,
|
||||
FirstName: userDB.FirstName,
|
||||
LastName: userDB.LastName,
|
||||
Password: userDB.Password,
|
||||
CreatedAt: userDB.CreatedAt,
|
||||
UpdatedAt: userDB.CreatedAt,
|
||||
}, nil
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Admin struct {
|
||||
ID int32
|
||||
Email string
|
||||
Password string
|
||||
AccessLevel int32
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Password string
|
||||
CreatedAt pgtype.Timestamp
|
||||
UpdatedAt pgtype.Timestamp
|
||||
}
|
@ -35,8 +35,10 @@ import (
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/infra/router"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/registry"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
|
||||
"git.vinchent.xyz/vinchent/howmuch/pkg/version/verflag"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/net/context"
|
||||
@ -105,28 +107,44 @@ func run() error {
|
||||
}
|
||||
|
||||
// Init DB
|
||||
dbPool, err := datastore.NewDB(
|
||||
fmt.Sprintf(
|
||||
"host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
|
||||
viper.GetString("db.host"),
|
||||
viper.GetInt("db.port"),
|
||||
viper.GetString("db.database"),
|
||||
viper.GetString("db.username"),
|
||||
viper.GetString("db.password"),
|
||||
viper.GetString("db.sslmode"),
|
||||
),
|
||||
dbConfString := fmt.Sprintf(
|
||||
"host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
|
||||
viper.GetString("db.host"),
|
||||
viper.GetInt("db.port"),
|
||||
viper.GetString("db.database"),
|
||||
viper.GetString("db.username"),
|
||||
viper.GetString("db.password"),
|
||||
viper.GetString("db.sslmode"),
|
||||
)
|
||||
if err != nil {
|
||||
log.FatalLog("DB connection failure", "err", err)
|
||||
// TODO: viper conf should be parsed into a struct directly
|
||||
dbExtraConf := &datastore.DbExtraConf{
|
||||
MaxOpenConns: viper.GetInt("db.max-open-conns"),
|
||||
MaxIdleConns: viper.GetInt("db.max-idle-conns"),
|
||||
MaxLifetime: viper.GetDuration("db.max-lifetime"),
|
||||
}
|
||||
defer dbPool.Close()
|
||||
dbConn := datastore.NewDB(dbConfString, dbExtraConf)
|
||||
if dbConn == nil {
|
||||
log.FatalLog("DB connection failure")
|
||||
}
|
||||
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
|
||||
r := registry.NewRegistry(dbPool)
|
||||
r := registry.NewRegistry(dbConn, cache)
|
||||
|
||||
engine := gin.Default()
|
||||
|
||||
engine = router.Routes(engine, r.NewAppController())
|
||||
engine = router.Routes(engine, r.NewAppController(), cache)
|
||||
|
||||
server := http.Server{
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"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
|
||||
func NewDB(dsn string) (*pgxpool.Pool, error) {
|
||||
conn, err := pgxpool.New(context.Background(), dsn)
|
||||
func NewDB(dsn string, opts interface{}) *sql.DB {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ping test the conn
|
||||
if err = conn.Ping(context.Background()); err != nil {
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, err
|
||||
return db, nil
|
||||
}
|
||||
|
@ -23,15 +23,25 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
|
||||
"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-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
|
||||
// Cors
|
||||
corsCfg := cors.DefaultConfig()
|
||||
@ -48,7 +58,27 @@ func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
|
||||
core.WriteResponse(ctx, errno.PageNotFoundErr, nil)
|
||||
})
|
||||
|
||||
engine.POST("/signup", func(ctx *gin.Context) { c.User.Signup(ctx) })
|
||||
v1 := engine.Group("/v1")
|
||||
{
|
||||
userV1 := v1.Group("/user")
|
||||
{
|
||||
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) })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
100
internal/howmuch/model/user.go
Normal file
100
internal/howmuch/model/user.go
Normal file
@ -0,0 +1,100 @@
|
||||
// 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 (from controller to service)
|
||||
|
||||
type UserCreateRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"first_name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type 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
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||
)
|
||||
|
||||
// registry is an implementation of Registry interface.
|
||||
@ -32,7 +34,8 @@ import (
|
||||
// It might holds other drivers when the projects grows. For example
|
||||
// the object needed to connect to Redis or Kafka.
|
||||
type registry struct {
|
||||
db *pgxpool.Pool
|
||||
db *sql.DB
|
||||
cache core.Cache
|
||||
}
|
||||
|
||||
// Registry returns a new app controller that will be used by main()/run()
|
||||
@ -43,15 +46,16 @@ type Registry interface {
|
||||
}
|
||||
|
||||
// NewRegistry returns a new Registry's implementation.
|
||||
func NewRegistry(db *pgxpool.Pool) Registry {
|
||||
return ®istry{db: db}
|
||||
func NewRegistry(db *sql.DB, cache core.Cache) Registry {
|
||||
return ®istry{db: db, cache: cache}
|
||||
}
|
||||
|
||||
// NewAppController creates a new AppController with controller struct for
|
||||
// each domain.
|
||||
func (r *registry) NewAppController() controller.AppController {
|
||||
return controller.AppController{
|
||||
User: r.NewUserController(),
|
||||
Admin: r.NewAdminController(),
|
||||
User: r.NewUserController(),
|
||||
Admin: r.NewAdminController(),
|
||||
Session: r.NewSessionController(),
|
||||
}
|
||||
}
|
||||
|
35
internal/howmuch/registry/session.go
Normal file
35
internal/howmuch/registry/session.go
Normal file
@ -0,0 +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
|
||||
|
||||
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
|
||||
func (r *registry) NewSessionController() controller.Session {
|
||||
u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
|
||||
return controller.NewSessionController(u, r.cache)
|
||||
}
|
@ -22,9 +22,14 @@
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// NewUserController returns a user controller's implementation
|
||||
func (r *registry) NewUserController() controller.User {
|
||||
return &controller.UserController{}
|
||||
u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
|
||||
return controller.NewUserController(u)
|
||||
}
|
||||
|
34
internal/howmuch/usecase/repo/db.go
Normal file
34
internal/howmuch/usecase/repo/db.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 repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type DBRepository interface {
|
||||
Transaction(
|
||||
ctx context.Context,
|
||||
txFunc func(txCtx context.Context, tx interface{}) (interface{}, error),
|
||||
) (interface{}, error)
|
||||
}
|
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)
|
||||
}
|
39
internal/howmuch/usecase/repo/user.go
Normal file
39
internal/howmuch/usecase/repo/user.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 repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(
|
||||
ctx context.Context,
|
||||
u *model.UserEntity,
|
||||
tx any,
|
||||
) (*model.UserEntity, error)
|
||||
GetByEmail(ctx context.Context, email string, tx any) (*model.UserEntity, error)
|
||||
GetByID(ctx context.Context, id int, tx any) (*model.UserEntity, error)
|
||||
}
|
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
|
||||
}
|
36
internal/howmuch/usecase/usecase/repomock/testdbrepo.go
Normal file
36
internal/howmuch/usecase/usecase/repomock/testdbrepo.go
Normal file
@ -0,0 +1,36 @@
|
||||
// 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 repomock
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type TestDBRepository struct{}
|
||||
|
||||
func (tdr *TestDBRepository) Transaction(
|
||||
ctx context.Context,
|
||||
txFunc func(txCtx context.Context, tx interface{}) (interface{}, error),
|
||||
) (interface{}, error) {
|
||||
return txFunc(ctx, nil)
|
||||
}
|
94
internal/howmuch/usecase/usecase/repomock/testuserrepo.go
Normal file
94
internal/howmuch/usecase/usecase/repomock/testuserrepo.go
Normal file
@ -0,0 +1,94 @@
|
||||
// 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 repomock
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var UserTestDummyErr = errors.New("dummy error")
|
||||
|
||||
type TestUserRepository struct{}
|
||||
|
||||
func (tur *TestUserRepository) Create(
|
||||
ctx context.Context,
|
||||
u *model.UserEntity,
|
||||
tx any,
|
||||
) (*model.UserEntity, error) {
|
||||
user := *u
|
||||
|
||||
user.ID = 123
|
||||
|
||||
if user.Email == "duplicate@error.com" {
|
||||
return nil, errors.New("blabla (SQLSTATE 23505)")
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (tur *TestUserRepository) GetByEmail(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
tx any,
|
||||
) (*model.UserEntity, error) {
|
||||
hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12)
|
||||
switch email {
|
||||
case "a@b.c":
|
||||
return &model.UserEntity{
|
||||
ID: 123,
|
||||
Email: "a@b.c",
|
||||
Password: string(hashedPwd),
|
||||
}, nil
|
||||
case "query@error.com":
|
||||
return nil, UserTestDummyErr
|
||||
case "inexist@error.com":
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
173
internal/howmuch/usecase/usecase/user.go
Normal file
173
internal/howmuch/usecase/usecase/user.go
Normal file
@ -0,0 +1,173 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"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/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
UserExisted = &errno.Errno{
|
||||
HTTP: http.StatusBadRequest,
|
||||
Code: errno.ErrorCode(errno.FailedOperationCode, "UserExisted"),
|
||||
Message: "email already existed.",
|
||||
}
|
||||
UserNotExist = &errno.Errno{
|
||||
HTTP: http.StatusBadRequest,
|
||||
Code: errno.ErrorCode(errno.ResourceNotFoundCode, "UserNotExist"),
|
||||
Message: "user does not exists.",
|
||||
}
|
||||
UserWrongPassword = &errno.Errno{
|
||||
HTTP: http.StatusBadRequest,
|
||||
Code: errno.ErrorCode(errno.AuthFailureCode, "UserWrongPassword"),
|
||||
Message: "wrong password.",
|
||||
}
|
||||
)
|
||||
|
||||
type userUsecase struct {
|
||||
userRepo repo.UserRepository
|
||||
dbRepo repo.DBRepository
|
||||
}
|
||||
|
||||
type User interface {
|
||||
Create(ctx context.Context, u *model.UserCreateRequest) (*model.UserInfoResponse, error)
|
||||
Exist(ctx context.Context, u *model.UserExistRequest) error
|
||||
GetUserBaseResponseByID(ctx context.Context, userID int) (*model.UserBaseResponse, error)
|
||||
}
|
||||
|
||||
func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
|
||||
return &userUsecase{
|
||||
userRepo: r,
|
||||
dbRepo: d,
|
||||
}
|
||||
}
|
||||
|
||||
func (uuc *userUsecase) Create(
|
||||
ctx context.Context,
|
||||
u *model.UserCreateRequest,
|
||||
) (*model.UserInfoResponse, error) {
|
||||
// Hash the password
|
||||
encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12)
|
||||
if err != nil {
|
||||
log.ErrorLog("encrypt password error", "err", err)
|
||||
return nil, errno.InternalServerErr
|
||||
}
|
||||
u.Password = string(encrypted)
|
||||
data, err := uuc.dbRepo.Transaction(
|
||||
ctx,
|
||||
func(txCtx context.Context, tx interface{}) (interface{}, error) {
|
||||
created, err := uuc.userRepo.Create(txCtx, &model.UserEntity{
|
||||
Email: u.Email,
|
||||
Password: u.Password,
|
||||
FirstName: u.FirstName,
|
||||
LastName: u.LastName,
|
||||
}, tx)
|
||||
if err != nil {
|
||||
match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
|
||||
if match {
|
||||
return nil, UserExisted
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: App log, maybe can be sent to some third party service.
|
||||
log.InfoLog(
|
||||
"created new user",
|
||||
"email",
|
||||
created.Email,
|
||||
"name",
|
||||
fmt.Sprintf("%s %s", created.FirstName, created.LastName),
|
||||
)
|
||||
|
||||
return created, err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
// TODO: We should wrap the error at service level
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userEntity := data.(*model.UserEntity)
|
||||
|
||||
user := &model.UserInfoResponse{
|
||||
ID: userEntity.ID,
|
||||
Email: userEntity.Email,
|
||||
FirstName: userEntity.FirstName,
|
||||
LastName: userEntity.LastName,
|
||||
CreatedAt: userEntity.CreatedAt,
|
||||
UpdatedAt: userEntity.UpdatedAt,
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistRequest) error {
|
||||
got, err := uuc.userRepo.GetByEmail(ctx, u.Email, nil)
|
||||
// Any query error?
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// User exists?
|
||||
if got == nil {
|
||||
return UserNotExist
|
||||
}
|
||||
|
||||
// Password correct?
|
||||
err = bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(u.Password))
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return UserWrongPassword
|
||||
}
|
||||
|
||||
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
|
||||
}
|
99
internal/howmuch/usecase/usecase/user_test.go
Normal file
99
internal/howmuch/usecase/usecase/user_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase/repomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
t.Run("normal create", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||
input := &model.UserCreateRequest{
|
||||
Email: "a@b.c",
|
||||
FirstName: "James",
|
||||
LastName: "Bond",
|
||||
Password: "verystrong",
|
||||
}
|
||||
want := &model.UserInfoResponse{
|
||||
ID: 123,
|
||||
}
|
||||
|
||||
got, err := userUsecase.Create(ctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, want.ID, got.ID)
|
||||
})
|
||||
|
||||
t.Run("duplicate create", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||
input := &model.UserCreateRequest{
|
||||
Email: "duplicate@error.com",
|
||||
FirstName: "James",
|
||||
LastName: "Bond",
|
||||
Password: "verystrong",
|
||||
}
|
||||
|
||||
_, err := userUsecase.Create(ctx, input)
|
||||
assert.EqualError(t, err, UserExisted.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserExist(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
User *model.UserExistRequest
|
||||
ExpErr error
|
||||
}{
|
||||
{"user exists", &model.UserExistRequest{
|
||||
Email: "a@b.c",
|
||||
Password: "strongHashed",
|
||||
}, nil},
|
||||
{"query error", &model.UserExistRequest{
|
||||
Email: "query@error.com",
|
||||
Password: "strongHashed",
|
||||
}, repomock.UserTestDummyErr},
|
||||
{"user doesn not exist", &model.UserExistRequest{
|
||||
Email: "inexist@error.com",
|
||||
Password: "strongHashed",
|
||||
}, UserNotExist},
|
||||
{"wrong password", &model.UserExistRequest{
|
||||
Email: "a@b.c",
|
||||
Password: "wrongHashed",
|
||||
}, UserWrongPassword},
|
||||
}
|
||||
|
||||
for _, tst := range testCases {
|
||||
ctx := context.Background()
|
||||
userUsecase := NewUserUsecase(&repomock.TestUserRepository{}, &repomock.TestDBRepository{})
|
||||
|
||||
err := userUsecase.Exist(ctx, tst.User)
|
||||
assert.ErrorIs(t, err, tst.ExpErr)
|
||||
}
|
||||
}
|
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
|
||||
}
|
@ -22,6 +22,19 @@
|
||||
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
type Context interface {
|
||||
// Context
|
||||
Deadline() (deadline time.Time, ok bool)
|
||||
Done() <-chan struct{}
|
||||
Err() error
|
||||
Value(key any) any
|
||||
|
||||
// Request
|
||||
Bind(obj any) error
|
||||
GetHeader(key string) string
|
||||
|
||||
// Response
|
||||
JSON(code int, obj any)
|
||||
}
|
||||
|
@ -24,18 +24,28 @@ package errno
|
||||
|
||||
import "net/http"
|
||||
|
||||
type PlatformLevelErrCode string
|
||||
|
||||
const (
|
||||
InternalErrorCode = "InternalError"
|
||||
InvalidParameterCode = "InvalidParameter"
|
||||
AuthFailureCode = "AuthFailure"
|
||||
ResourceNotFoundCode = "ResourceNotFound"
|
||||
FailedOperationCode = "FailedOperation"
|
||||
)
|
||||
|
||||
var (
|
||||
OK = &Errno{HTTP: http.StatusOK, Code: "", Message: ""}
|
||||
|
||||
InternalServerErr = &Errno{
|
||||
HTTP: http.StatusInternalServerError,
|
||||
Code: "InternalError",
|
||||
Code: InternalErrorCode,
|
||||
Message: "Internal server error",
|
||||
}
|
||||
|
||||
PageNotFoundErr = &Errno{
|
||||
HTTP: http.StatusNotFound,
|
||||
Code: "ResourceNotFound.PageNotFound",
|
||||
Code: ErrorCode(ResourceNotFoundCode, "PageNotFound"),
|
||||
Message: "Page not found",
|
||||
}
|
||||
)
|
||||
|
@ -28,6 +28,10 @@ type Errno struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func ErrorCode(platformErrCode string, resourceErrCode string) string {
|
||||
return platformErrCode + "." + resourceErrCode
|
||||
}
|
||||
|
||||
// Error implements Error() method in error interface
|
||||
func (err *Errno) Error() string {
|
||||
return err.Message
|
||||
|
@ -26,6 +26,8 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
@ -100,6 +102,30 @@ func NewLogger(opts *Options) *zapLogger {
|
||||
return &zapLogger{z: z}
|
||||
}
|
||||
|
||||
// CtxLog writes context's information into the log
|
||||
func CtxLog(ctx core.Context) *zapLogger {
|
||||
return std.CtxLog(ctx)
|
||||
}
|
||||
|
||||
func (z *zapLogger) CtxLog(ctx core.Context) *zapLogger {
|
||||
zz := z.clone()
|
||||
|
||||
if rid := ctx.GetHeader(shared.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
|
||||
}
|
||||
|
||||
func (z *zapLogger) clone() *zapLogger {
|
||||
zz := *z
|
||||
return &zz
|
||||
}
|
||||
|
||||
func (z *zapLogger) FatalLog(msg string, keyValues ...interface{}) {
|
||||
z.z.Sugar().Fatalw(msg, keyValues...)
|
||||
}
|
||||
|
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
|
||||
|
||||
import (
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const requestID = "X-Request-Id"
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
var rid string
|
||||
|
||||
if rid = ctx.GetHeader(requestID); rid != "" {
|
||||
ctx.Request.Header.Add(requestID, rid)
|
||||
if rid = ctx.GetHeader(shared.XRequestID); rid != "" {
|
||||
ctx.Request.Header.Add(shared.XRequestID, rid)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
rid = uuid.NewString()
|
||||
ctx.Request.Header.Add(requestID, rid)
|
||||
ctx.Header(requestID, rid)
|
||||
ctx.Request.Header.Add(shared.XRequestID, rid)
|
||||
ctx.Header(shared.XRequestID, rid)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
@ -24,33 +24,15 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
|
||||
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type header struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func performRequest(
|
||||
r http.Handler,
|
||||
method, path string,
|
||||
headers ...header,
|
||||
) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
for _, h := range headers {
|
||||
req.Header.Add(h.Key, h.Value)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRequestID(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(RequestID())
|
||||
@ -58,21 +40,28 @@ func TestRequestID(t *testing.T) {
|
||||
wanted := "123"
|
||||
|
||||
r.GET("/example", func(c *gin.Context) {
|
||||
got = c.GetHeader(requestID)
|
||||
got = c.GetHeader(shared.XRequestID)
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
r.POST("/example", func(c *gin.Context) {
|
||||
got = c.GetHeader(requestID)
|
||||
got = c.GetHeader(shared.XRequestID)
|
||||
c.String(http.StatusAccepted, "ok")
|
||||
})
|
||||
|
||||
// Test with Request ID
|
||||
_ = performRequest(r, "GET", "/example?a=100", header{requestID, wanted})
|
||||
_ = test.PerformRequest(
|
||||
t,
|
||||
r,
|
||||
"GET",
|
||||
"/example?a=100",
|
||||
nil,
|
||||
test.Header{Key: shared.XRequestID, Value: wanted},
|
||||
)
|
||||
assert.Equal(t, "123", got)
|
||||
|
||||
res := performRequest(r, "GET", "/example?a=100")
|
||||
res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
|
||||
assert.NotEqual(t, "", got)
|
||||
assert.NoError(t, uuid.Validate(got))
|
||||
assert.Equal(t, res.Header()[requestID][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"
|
||||
)
|
52
internal/pkg/test/request.go
Normal file
52
internal/pkg/test/request.go
Normal file
@ -0,0 +1,52 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func PerformRequest(
|
||||
t testing.TB,
|
||||
r http.Handler,
|
||||
method, path string,
|
||||
body io.Reader,
|
||||
headers ...Header,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, body)
|
||||
for _, h := range headers {
|
||||
req.Header.Add(h.Key, h.Value)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
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,2 @@
|
||||
ALTER TABLE "admin"
|
||||
ADD CONSTRAINT unique_email UNIQUE ("email");
|
@ -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
|
6
sqlc.yml
6
sqlc.yml
@ -23,9 +23,9 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "internal/app/adapter/repo/sqlc"
|
||||
queries: "internal/howmuch/adapter/repo/sqlc"
|
||||
schema: "migrations"
|
||||
gen:
|
||||
go:
|
||||
out: "internal/app/controller/repo/sqlc"
|
||||
sql_package: "pgx/v5"
|
||||
out: "internal/howmuch/adapter/repo/sqlc"
|
||||
emit_interface: true
|
||||
|
@ -1 +0,0 @@
|
||||
exit status 2exit status 2exit status 2exit status 2exit status 2
|
15
web/.eslintrc.cjs
Normal file
15
web/.eslintrc.cjs
Normal file
@ -0,0 +1,15 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
30
web/.gitignore
vendored
Normal file
30
web/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
8
web/.prettierrc.json
Normal file
8
web/.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
7
web/.vscode/extensions.json
vendored
Normal file
7
web/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
22
web/Makefile
Normal file
22
web/Makefile
Normal file
@ -0,0 +1,22 @@
|
||||
.PHONY: all
|
||||
all: add-copyright format lint build test
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
@npm install && npm run build
|
||||
|
||||
.PHONY: format
|
||||
format: # format code.
|
||||
@npm run format
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
@npm run lint
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@npm run test:unit run
|
||||
|
||||
.PHONY: add-copyright
|
||||
add-copyright: # add license to file headers.
|
||||
@addlicense -v -f ../LICENSE ./src --skip-files=database.yml
|
45
web/README.md
Normal file
45
web/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
1
web/env.d.ts
vendored
Normal file
1
web/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
5988
web/package-lock.json
generated
Normal file
5988
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user