Compare commits

..

75 Commits

Author SHA1 Message Date
dd999b9355 refacto: refacto repo layer code while adding new usecase methods
All checks were successful
Build and test / Build (push) Successful in 2m19s
2024-10-26 17:27:33 +02:00
14ee642aab refacto: add db tx as a possible input for repo methods
All checks were successful
Build and test / Build (push) Successful in 2m22s
2024-10-25 23:52:43 +02:00
b30a5c5c2d feat: implement repo expense methods
All checks were successful
Build and test / Build (push) Successful in 2m28s
2024-10-24 23:39:13 +02:00
58cff774e6 feat: add expense repo type conversion 2024-10-24 22:33:24 +02:00
716a58d44c fix: use simple slices instead of []*T 2024-10-24 22:32:57 +02:00
de7c6f7223 test: add event repo tests 2024-10-23 23:29:44 +02:00
0258ff6620 web: add test login and signup page
All checks were successful
Build and test / Build (push) Successful in 2m28s
2024-10-23 22:39:19 +02:00
0da8b29507 test: try to test user repo and event repo
All checks were successful
Build and test / Build (push) Successful in 2m27s
2024-10-23 09:54:00 +02:00
304651e7ff feat: implement event repo methods 2024-10-20 23:27:17 +02:00
74ae6b7877 doc: add readme 2024-10-20 23:26:45 +02:00
b4259e9a51 doc: add licence for event.go 2024-10-20 21:27:26 +02:00
46c14b63ea feat: add a method for event. And introduce the mock
All checks were successful
Build and test / Build (push) Successful in 2m27s
2024-10-20 21:26:12 +02:00
c27dfc687f minor: cleanup 2024-10-19 17:14:08 +02:00
3d616bff50 db: finish sql commands
All checks were successful
Build and test / Build (push) Successful in 2m23s
2024-10-19 17:08:05 +02:00
dac36db284 db: add more tables 2024-10-19 13:28:02 +02:00
80a5f1f8a8 fix: CHANGE NAMES AGAIN. Just want to be clearer
All checks were successful
Build and test / Build (push) Successful in 2m20s
2024-10-18 23:24:31 +02:00
a55fd26f90 repo: add some more sql for events 2024-10-18 21:41:53 +02:00
dde4eb337c repo: add some sql for events
All checks were successful
Build and test / Build (push) Successful in 2m29s
2024-10-18 21:15:27 +02:00
39eaae46d8 db: add migrations 2024-10-18 19:36:31 +02:00
86832cf1f9 test: add wrong params test cases for session create 2024-10-17 22:12:31 +02:00
350a6f86d9 doc: add diary for use cases
All checks were successful
Build and test / Build (push) Successful in 2m21s
2024-10-17 21:56:23 +02:00
a7a915d825 feat: Impl event while refactoring user domain
All checks were successful
Build and test / Build (push) Successful in 2m21s
2024-10-16 23:47:06 +02:00
29633e0e95 feat: use PO for repo layer
All checks were successful
Build and test / Build (push) Successful in 2m26s
2024-10-16 09:59:03 +02:00
0e05924585 feat: rework entities design 2024-10-16 09:49:07 +02:00
dfc2d1b2eb feat: add transaction and participation associations 2024-10-16 09:23:32 +02:00
3b18a15494 feat: add event and expense BOs
All checks were successful
Build and test / Build (push) Successful in 2m18s
2024-10-16 00:13:38 +02:00
c94b0b532b feat: add money value object 2024-10-16 00:05:05 +02:00
606289be1a fix: create DTO in model and handle it in service level 2024-10-16 00:04:28 +02:00
382da3d811 test: add test for session delete 2024-10-15 21:39:08 +02:00
322b441c70 doc: add diary
All checks were successful
Build and test / Build (push) Successful in 2m21s
2024-10-15 21:08:06 +02:00
20ef75b1aa feat: rework db conn to use sql.DB for better portability
All checks were successful
Build and test / Build (push) Successful in 2m20s
2024-10-15 14:01:53 +02:00
1fb84a3ff4 feat: implement session delete using redis 2024-10-15 10:14:40 +02:00
2fe834fe55 api: add session delete api doc 2024-10-13 22:22:44 +02:00
544ccbe1ca doc: add diary
All checks were successful
Build and test / Build (push) Successful in 2m18s
2024-10-13 22:04:12 +02:00
9290bcf88c fix: make create session works 2024-10-13 21:55:26 +02:00
7ff91bab1d deploy: add redis image
All checks were successful
Build and test / Build (push) Successful in 2m19s
2024-10-13 21:11:23 +02:00
ca2985abb4 feat: add session create 2024-10-13 21:10:33 +02:00
798b9a7695 feat: Add jwt token pkg 2024-10-13 13:36:22 +02:00
71926b2197 feat: Use gin default validator
All checks were successful
Build and test / Build (push) Successful in 2m9s
2024-10-12 23:30:36 +02:00
79739e3751 fix: move testdb/testuser to a mock dir to be clearer 2024-10-12 18:52:27 +02:00
addddb152a fix: move testdb/testuser to usecase dir. repo should only be interface defs
All checks were successful
Build and test / Build (push) Successful in 2m23s
2024-10-12 18:38:55 +02:00
3e09afd4b0 feat: add usecase to check if a user exists 2024-10-12 18:33:19 +02:00
c312b4e2c8 fix(mkfile): fix make sqlc 2024-10-12 18:31:18 +02:00
eee5084821 feat: update openapi 2024-10-12 17:11:16 +02:00
ce3076047a feat: add t.Helper to request helper 2024-10-12 17:09:03 +02:00
9b6282a101 feat: make the request test helper public 2024-10-12 17:07:24 +02:00
a3c2ade9fb feat: create session. (also print the x-rid into the log) 2024-10-11 23:24:29 +02:00
be7f57d5a1 docs: Add design schemas
All checks were successful
Build and test / Build (push) Successful in 2m1s
2024-10-09 23:26:21 +02:00
e1ca08db3a fix(ci): install npm
All checks were successful
Build and test / Build (push) Successful in 1m55s
2024-10-08 23:43:05 +02:00
7209bc9c70 fix(ci): run-p not found
Some checks failed
Build and test / Build (push) Failing after 1m44s
2024-10-08 23:33:34 +02:00
d35b47c7e6 fix: badge
Some checks failed
Build and test / Build (push) Failing after 1m39s
2024-10-08 23:29:00 +02:00
5379895c4c feat: test gitea actions with frontend build and test!
Some checks failed
Build and test / Build (push) Has been cancelled
2024-10-08 23:28:00 +02:00
8e73dc5f0b feat: test gitea actions with a real build!
All checks were successful
Build and test / Build (push) Successful in 2m10s
2024-10-08 23:22:00 +02:00
cc505e5a74 feat: test gitea actions!
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m4s
2024-10-08 21:22:18 +02:00
8f184ba797 feat: add Vue init project 2024-10-08 00:17:36 +02:00
cb7a4bf5c5 fix: change API design to be more RESTful 2024-10-07 23:25:25 +02:00
c1173b4bcc feat: add test case for usecase user.create 2024-10-06 23:21:54 +02:00
b7697bc89b feat: hash the user password 2024-10-06 22:07:51 +02:00
ba8570857d feat: add validate for user signup 2024-10-06 21:54:29 +02:00
344485d082 fix: make user signup work for the minimum
A lot of work is still to be done ...
2024-10-06 18:29:10 +02:00
7b8abf8e5c feat: Create user usecase 2024-10-06 16:18:28 +02:00
4546665461 feat(sql): Add unique constraint to admin's email 2024-10-06 13:44:27 +02:00
2c1beb30f6 fix(db): use a normal pgx.Conn first 2024-10-06 13:33:40 +02:00
43a1d0509c feat(mkfile): build sqlc 2024-10-06 13:18:21 +02:00
b14b8788ab fix: wrong directory for sqlc generated code 2024-10-06 10:29:09 +02:00
4cdfe753c8 doc: add diary 2024-10-06 00:17:45 +02:00
332871d403 feat: route up to the controller level 2024-10-06 00:15:29 +02:00
c00cbf35f1 feat: Integrate db infra 2024-10-05 23:51:11 +02:00
1d753783ce fix: addlicense ignore sqlc dir 2024-10-05 23:23:35 +02:00
a6abdf504d fix: change sqlc output directory 2024-10-05 23:23:09 +02:00
426471d3b1 fix: change db configs in the config file 2024-10-05 23:22:49 +02:00
8edea3f0b8 fix: change helpers to config.go and add some default configs 2024-10-05 23:22:19 +02:00
d7b007b851 feat: introduce sqlc for the datastore layer 2024-10-05 18:58:14 +02:00
8f9e552696 feat: start to use openapi 2024-10-05 18:02:42 +02:00
28bc869c64 feat: Add regitry and adapter level code for user/admin 2024-10-05 16:41:49 +02:00
134 changed files with 13689 additions and 822 deletions

View File

@ -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

View 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
View File

@ -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

View File

@ -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
@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

423
README.md
View File

@ -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.
---
![Gitea action](https://git.vinchent.xyz/vinchent/howmuch/actions/workflows/demo.yaml/badge.svg)
## 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; etcds consistency is crucial for correctly scheduling
and operating services. The Kubernetes API server persists cluster state
into etcd. It uses etcds watch API to monitor the cluster and roll out
critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
> cluster management; etcds consistency is crucial for correctly scheduling
> and operating services. The Kubernetes API server persists cluster state
> into etcd. It uses etcds 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.
@ -197,3 +212,373 @@ type User struct {
```
Use Buffalo pop `Soda CLI` to create database migrations.
### 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.
![Core user story part 1](./docs/howmuch_us1.drawio.png)
![Database model](./docs/howmuch.drawio.png)
### 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?

View File

@ -0,0 +1,828 @@
# 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.
swagger: "2.0"
openapi: 3.1.0
info:
title: Swagger Petstore - OpenAPI 3.1
description: |-
This is a sample Pet Store Server based on the OpenAPI 3.1 specification. You can find out more about
Swagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!
You can now help us improve the API whether it's by making changes to the definition itself or to the code.
That way, with time, we can improve the API in general, and expose some of the new features in OAS3.
Some useful links:
- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)
- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)
termsOfService: http://swagger.io/terms/
contact:
email: apiteam@swagger.io
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
version: 1.0.11
externalDocs:
description: Find out more about Swagger
url: http://swagger.io
servers:
- url: https://petstore3.swagger.io/api/v3
tags:
- name: pet
description: Everything about your Pets
externalDocs:
description: Find out more
url: http://swagger.io
- name: store
description: Access to Petstore orders
externalDocs:
description: Find out more about our store
url: http://swagger.io
- name: user
description: Operations about user
paths:
/pet:
put:
tags:
- pet
summary: Update an existing pet
description: Update an existing pet by Id
operationId: updatePet
requestBody:
description: Update an existent pet in the store
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
required: true
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'422':
description: Validation exception
security:
- petstore_auth:
- write:pets
- read:pets
post:
tags:
- pet
summary: Add a new pet to the store
description: Add a new pet to the store
operationId: addPet
requestBody:
description: Create a new pet in the store
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
required: true
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid input
'422':
description: Validation exception
security:
- petstore_auth:
- write:pets
- read:pets
/pet/findByStatus:
get:
tags:
- pet
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: false
explode: true
schema:
type: string
default: available
enum:
- available
- pending
- sold
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- write:pets
- read:pets
/pet/findByTags:
get:
tags:
- pet
summary: Finds Pets by tags
description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: false
explode: true
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- write:pets
- read:pets
/pet/{petId}:
get:
tags:
- pet
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
- petstore_auth:
- write:pets
- read:pets
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
- name: name
in: query
description: Name of pet that needs to be updated
schema:
type: string
- name: status
in: query
description: Status of pet that needs to be updated
schema:
type: string
responses:
'400':
description: Invalid input
security:
- petstore_auth:
- write:pets
- read:pets
delete:
tags:
- pet
summary: Deletes a pet
description: delete a pet
operationId: deletePet
parameters:
- name: api_key
in: header
description: ''
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- write:pets
- read:pets
/pet/{petId}/uploadImage:
post:
tags:
- pet
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
- name: additionalMetadata
in: query
description: Additional Metadata
required: false
schema:
type: string
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- write:pets
- read:pets
/store/inventory:
get:
tags:
- store
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/store/order:
post:
tags:
- store
summary: Place an order for a pet
description: Place a new order in the store
operationId: placeOrder
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Order'
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid input
'422':
description: Validation exception
/store/order/{orderId}:
get:
tags:
- store
summary: Find purchase order by ID
description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of order that needs to be fetched
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- store
summary: Delete purchase order by ID
description: For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
requestBody:
description: Created user object
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
responses:
default:
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: Creates list of users with given input array
operationId: createUsersWithListInput
requestBody:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
default:
description: successful operation
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: false
schema:
type: string
- name: password
in: query
description: The password for login in clear text
required: false
schema:
type: string
responses:
'200':
description: successful operation
headers:
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
parameters: []
responses:
default:
description: successful operation
/user/{username}:
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: 'The name that needs to be fetched. Use user1 for testing. '
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Update user
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that need to be deleted
required: true
schema:
type: string
requestBody:
description: Update an existent user in the store
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
responses:
default:
description: successful operation
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
components:
schemas:
Order:
type: object
properties:
id:
type: integer
format: int64
examples: [10]
petId:
type: integer
format: int64
examples: [198772]
quantity:
type: integer
format: int32
examples: [7]
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
examples: [approved]
enum:
- placed
- approved
- delivered
complete:
type: boolean
xml:
name: order
Customer:
type: object
properties:
id:
type: integer
format: int64
examples: [100000]
username:
type: string
examples: [fehguy]
address:
type: array
xml:
name: addresses
wrapped: true
items:
$ref: '#/components/schemas/Address'
xml:
name: customer
Address:
type: object
properties:
street:
type: string
examples: [437 Lytton]
city:
type: string
examples: [Palo Alto]
state:
type: string
examples: [CA]
zip:
type: string
examples: ['94301']
xml:
name: address
Category:
type: object
properties:
id:
type: integer
format: int64
examples: [1]
name:
type: string
examples: [Dogs]
xml:
name: category
User:
type: object
properties:
id:
type: integer
format: int64
examples: [10]
username:
type: string
examples: [theUser]
firstName:
type: string
examples: [John]
lastName:
type: string
examples: [James]
email:
type: string
examples: [john@email.com]
password:
type: string
examples: ['12345']
phone:
type: string
examples: ['12345']
userStatus:
type: integer
description: User Status
format: int32
examples: [1]
xml:
name: user
Tag:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: tag
Pet:
required:
- name
- photoUrls
type: object
properties:
id:
type: integer
format: int64
examples: [10]
name:
type: string
examples: [doggie]
category:
$ref: '#/components/schemas/Category'
photoUrls:
type: array
xml:
wrapped: true
items:
type: string
xml:
name: photoUrl
tags:
type: array
xml:
wrapped: true
items:
$ref: '#/components/schemas/Tag'
status:
type: string
description: pet status in the store
enum:
- available
- pending
- sold
xml:
name: pet
ApiResponse:
type: object
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: string
xml:
name: '##default'
requestBodies:
Pet:
description: Pet object that needs to be added to the store
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
UserArray:
description: List of user object
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: https://petstore3.swagger.io/oauth/authorize
scopes:
write:pets: modify pets in your account
read:pets: read your pets
api_key:
type: apiKey
name: api_key
in: header

View File

@ -21,795 +21,185 @@
# SOFTWARE.
swagger: "2.0"
openapi: "3.1.0"
openapi: 3.1.0
info:
title: Howmuch expense-sharing
title: Howmuch Expense-sharing app
description: |-
A simple web application that can help you share your expense with your
friends.
contact:
email: vinchent@vinchent.vinchent
email: vinchent@vinchent.xyz
license:
name: MIT
url: https://opensource.org/license/MIT
version: 0.0.1
# externalDocs:
# description: Find out more about Swagger
# url: http://swagger.io
version: '0.0.1'
servers:
- url: http://localhost:8080/v1
- url: https:/localhost:8000/v1
tags:
- name: event
description: Event with your friends
- name: expense
description: Who paid how much for whom else
- name: user
description: Operations about user
- name: session
paths:
/event:
put:
/user/create:
post:
tags:
- event
summary: Update an existing pet
description: Update an existing pet by Id
operationId: updatePet
- user
description: Create a new user
requestBody:
description: Update an existent pet in the store
description: Create a new user
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
required: true
$ref: '#/components/schemas/UserCreateRequest'
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'422':
description: Validation exception
security:
- petstore_auth:
- write:pets
- read:pets
post:
tags:
- event
summary: Add a new pet to the store
description: Add a new pet to the store
operationId: addPet
requestBody:
description: Create a new pet in the store
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
required: true
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid input
'422':
description: Validation exception
security:
- petstore_auth:
- write:pets
- read:pets
/event/findByStatus:
get:
tags:
- event
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: false
explode: true
schema:
type: string
default: available
enum:
- available
- pending
- sold
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- write:pets
- read:pets
/event/findByTags:
get:
tags:
- event
summary: Finds Pets by tags
description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: false
explode: true
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- write:pets
- read:pets
/event/{eventId}:
get:
tags:
- event
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
- petstore_auth:
- write:pets
- read:pets
post:
tags:
- event
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
- name: name
in: query
description: Name of pet that needs to be updated
schema:
type: string
- name: status
in: query
description: Status of pet that needs to be updated
schema:
type: string
responses:
'400':
description: Invalid input
security:
- petstore_auth:
- write:pets
- read:pets
delete:
tags:
- event
summary: Deletes a pet
description: delete a pet
operationId: deletePet
parameters:
- name: api_key
in: header
description: ''
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- write:pets
- read:pets
/event/{eventId}/uploadImage:
post:
tags:
- event
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
- name: additionalMetadata
in: query
description: Additional Metadata
required: false
schema:
type: string
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- write:pets
- read:pets
/expense/inventory:
get:
tags:
- expense
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
responses:
'200':
description: successful operation
description: Client side error
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/expense/order:
post:
tags:
- expense
summary: Place an order for a pet
description: Place a new order in the store
operationId: placeOrder
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Order'
responses:
'200':
description: successful operation
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/Order'
'400':
description: Invalid input
'422':
description: Validation exception
/expense/order/{orderId}:
get:
tags:
- expense
summary: Find purchase order by ID
description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of order that needs to be fetched
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- expense
summary: Delete purchase order by ID
description: For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
$ref: '#/components/schemas/ErrResponse'
/session/create:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
- session
description: Create a new session for a user
requestBody:
description: Created user object
description: Create session
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
responses:
default:
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: Creates list of users with given input array
operationId: createUsersWithListInput
requestBody:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
$ref: '#/components/schemas/SessionCreateRequest'
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
type: object
properties:
token:
type: string
example: fakjshdflauhkjhsometokenakjsdhfaksj
'400':
description: Client side error
content:
application/json:
schema:
$ref: '#/components/schemas/User'
default:
description: successful operation
/user/login:
get:
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:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: false
schema:
type: string
- name: password
in: query
description: The password for login in clear text
required: false
schema:
type: string
- session
description: Delete an existing session for a user
responses:
'200':
description: successful operation
description: Successful operation
headers:
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
parameters: []
responses:
default:
description: successful operation
/user/{username}:
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: 'The name that needs to be fetched. Use user1 for testing. '
required: true
schema:
type: string
responses:
'200':
description: successful operation
description: Client side error
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
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/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Update user
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that need to be deleted
required: true
schema:
type: string
requestBody:
description: Update an existent user in the store
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
responses:
default:
description: successful operation
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
$ref: '#/components/schemas/ErrResponse'
security:
- jwt: []
components:
schemas:
Order:
UserCreateRequest:
type: object
properties:
id:
type: integer
format: int64
examples: [10]
petId:
type: integer
format: int64
examples: [198772]
quantity:
type: integer
format: int32
examples: [7]
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
examples: [approved]
enum:
- placed
- approved
- delivered
complete:
type: boolean
xml:
name: order
Customer:
type: object
properties:
id:
type: integer
format: int64
examples: [100000]
username:
type: string
examples: [fehguy]
address:
type: array
xml:
name: addresses
wrapped: true
items:
$ref: '#/components/schemas/Address'
xml:
name: customer
Address:
type: object
properties:
street:
type: string
examples: [437 Lytton]
city:
type: string
examples: [Palo Alto]
state:
type: string
examples: [CA]
zip:
type: string
examples: ['94301']
xml:
name: address
Category:
type: object
properties:
id:
type: integer
format: int64
examples: [1]
name:
type: string
examples: [Dogs]
xml:
name: category
User:
type: object
properties:
id:
type: integer
format: int64
examples: [10]
username:
type: string
examples: [theUser]
firstName:
type: string
examples: [John]
lastName:
type: string
examples: [James]
email:
type: string
examples: [john@email.com]
example: bruce@wayne.com
first_name:
type: string
example: Bruce
last_name:
type: string
example: Wayne
password:
type: string
examples: ['12345']
phone:
type: string
examples: ['12345']
userStatus:
type: integer
description: User Status
format: int32
examples: [1]
xml:
name: user
Tag:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: tag
Pet:
example: verystrongpassword
required:
- name
- photoUrls
- email
- fist_name
- last_name
- password
SessionCreateRequest:
type: object
properties:
id:
type: integer
format: int64
examples: [10]
name:
email:
type: string
examples: [doggie]
category:
$ref: '#/components/schemas/Category'
photoUrls:
type: array
xml:
wrapped: true
items:
type: string
xml:
name: photoUrl
tags:
type: array
xml:
wrapped: true
items:
$ref: '#/components/schemas/Tag'
status:
example: bruce@wayne.com
password:
type: string
description: pet status in the store
enum:
- available
- pending
- sold
xml:
name: pet
ApiResponse:
example: verystrongpassword
required:
- email
- password
ErrResponse:
type: object
properties:
code:
type: integer
format: int32
type:
type: string
example: InternalError
message:
type: string
xml:
name: '##default'
requestBodies:
Pet:
description: Pet object that needs to be added to the store
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
UserArray:
description: List of user object
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
example: "Server internal error."
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: https://petstore3.swagger.io/oauth/authorize
scopes:
write:pets: modify pets in your account
read:pets: read your pets
api_key:
type: apiKey
name: api_key
in: header
jwt:
name: Bearer authentication
type: http
bearerFormat: "JWT"
scheme: bearer

View File

@ -1,24 +1,28 @@
dev-mode: true
web:
addr: :8080
addr: :8000
shutdown-timeout: 10
token-secret: nzMC12IJBMiiV2AAktTFpZP4BbGAf09lFPV_sATKcwI
token-expiry-time: 24h
db:
# DB host
host: 127.0.0.1
# DB username
username: howmuch
username: postgres
# DB password
password: howmuch
password: example
# DB name
database: howmuch
# max idle connections
max-idle-connections: 100
# max open connections
max-open-connections: 100
# max connection life time
max-connection-life-time: 10s
max-open-conns: 100
max-idle-conns: 100
max-lifetime: 10s
cache:
host: 127.0.0.1:6379
password: ""
log:
level: debug

View File

@ -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
View File

@ -0,0 +1,7 @@
# Platform level error code design
- InternalError
- InvalidParameter
- AuthFailure
- ResourceNotFound
- FailedOperation

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

21
go.mod
View File

@ -6,31 +6,42 @@ 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
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@ -54,10 +65,10 @@ 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.23.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

48
go.sum
View File

@ -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,12 +39,14 @@ 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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -46,6 +56,14 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@ -76,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=
@ -127,19 +149,21 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View 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 controller
import (
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
)
type Admin interface {
CreateUser(core.Context)
GetUserById(core.Context)
UpdateUser(core.Context)
DeleteUser(core.Context)
ListUsers(core.Context)
}
type AdminController struct{}
func (ac *AdminController) CreateUser(core.Context) {
}
func (ac *AdminController) GetUserById(core.Context) {
}
func (ac *AdminController) UpdateUser(core.Context) {
}
func (ac *AdminController) DeleteUser(core.Context) {
}
func (ac *AdminController) ListUsers(core.Context) {
}

View File

@ -0,0 +1,33 @@
// 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
// AppController is the controller structure that holds service controllers.
type AppController struct {
// User must implement User interface
User interface{ User }
Admin interface{ Admin }
Session interface{ Session }
}

View 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")
}

View File

@ -0,0 +1,190 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package 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)
}

View 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 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
}

View 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 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 {
Create(core.Context)
UpdateInfo(*gin.Context)
ChangePassword(*gin.Context)
}
type UserController struct {
userUsecase usecase.User
}
var UserParamsErr = &errno.Errno{
HTTP: http.StatusBadRequest,
Code: errno.ErrorCode(errno.InvalidParameterCode, "UserParamsErr"),
Message: "user info is not correct",
}
func NewUserController(us usecase.User) User {
return &UserController{
userUsecase: us,
}
}
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) UpdateInfo(ctx *gin.Context) {
}
func (uc *UserController) ChangePassword(ctx *gin.Context) {
}

View 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
}

View File

@ -0,0 +1,276 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
)
type eventRepository struct {
queries *sqlc.Queries
}
func NewEventRepository(db *sql.DB) repo.EventRepository {
return &eventRepository{
queries: sqlc.New(db),
}
}
// Create implements repo.EventRepository.
func (e *eventRepository) Create(
ctx context.Context,
evEntity *model.EventEntity,
tx any,
) (*model.EventEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
event, err := queries.InsertEvent(timeoutCtx, sqlc.InsertEventParams{
Name: evEntity.Name,
Description: sql.NullString{String: evEntity.Description, Valid: true},
TotalAmount: sql.NullInt32{Int32: int32(evEntity.TotalAmount), Valid: true},
DefaultCurrency: evEntity.DefaultCurrency,
OwnerID: int32(evEntity.OwnerID),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err != nil {
return nil, err
}
return &model.EventEntity{
ID: int(event.ID),
Name: event.Name,
Description: event.Description.String,
TotalAmount: int(event.TotalAmount.Int32),
DefaultCurrency: event.DefaultCurrency,
OwnerID: int(event.OwnerID),
CreatedAt: event.CreatedAt,
UpdatedAt: event.UpdatedAt,
}, nil
}
func convToEventRetrieved(eventDTO *sqlc.GetEventByIDRow) (*model.EventRetrieved, error) {
// marshal owner and users
var owner model.UserBaseRetrieved
err := json.Unmarshal(eventDTO.Owner, &owner)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
var users []model.UserBaseRetrieved
err = json.Unmarshal(eventDTO.Users, &users)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
eventRetrieved := &model.EventRetrieved{
ID: int(eventDTO.ID),
Name: eventDTO.Name,
Description: eventDTO.Description.String,
TotalAmount: model.MakeMoney(
int(eventDTO.TotalAmount.Int32),
model.Currency(eventDTO.DefaultCurrency),
),
DefaultCurrency: model.Currency(eventDTO.DefaultCurrency),
CreatedAt: eventDTO.CreatedAt,
UpdatedAt: eventDTO.UpdatedAt,
Owner: &owner,
Users: users,
}
return eventRetrieved, nil
}
// GetByID implements repo.EventRepository.
func (e *eventRepository) GetByID(
ctx context.Context,
eventID int,
tx any,
) (*model.EventRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
eventDTO, err := queries.GetEventByID(timeoutCtx, int32(eventID))
if err != nil {
log.ErrorLog("query error", "err", err)
return nil, err
}
return convToEventRetrieved(&eventDTO)
}
func convToEventList(eventsDTO []sqlc.ListEventsByUserIDRow) ([]model.EventListRetrieved, error) {
events := make([]model.EventListRetrieved, len(eventsDTO))
for i, evDTO := range eventsDTO {
var owner model.UserBaseRetrieved
err := json.Unmarshal(evDTO.Owner, &owner)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
ev := model.EventListRetrieved{
ID: int(evDTO.ID),
Name: evDTO.Name,
Description: evDTO.Description.String,
Owner: &owner,
CreatedAt: evDTO.CreatedAt,
}
events[i] = ev
}
return events, nil
}
// ListEventsByUserID implements repo.EventRepository.
func (e *eventRepository) ListEventsByUserID(
ctx context.Context,
userID int,
tx any,
) ([]model.EventListRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
eventsDTO, err := queries.ListEventsByUserID(timeoutCtx, int32(userID))
if err != nil {
log.ErrorLog("query error", "err", err)
return nil, err
}
return convToEventList(eventsDTO)
}
// UpdateInfo implements repo.EventRepository.
func (e *eventRepository) UpdateEventByID(
ctx context.Context,
event *model.EventUpdateEntity,
tx any,
) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
err := queries.UpdateEventByID(timeoutCtx, sqlc.UpdateEventByIDParams{
ID: int32(event.ID),
Name: event.Name,
Description: sql.NullString{String: event.Description, Valid: true},
UpdatedAt: time.Now(),
})
return err
}
// GetParticipation implements repo.EventRepository.
func (e *eventRepository) GetParticipation(
ctx context.Context,
userID, eventID int,
tx any,
) (*model.ParticipationEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
partDTO, err := queries.GetParticipation(timeoutCtx, sqlc.GetParticipationParams{
UserID: int32(userID),
EventID: int32(eventID),
})
if errors.Is(err, sql.ErrNoRows) {
// No error, but participation not found
return nil, nil
}
if err != nil {
return nil, err
}
return &model.ParticipationEntity{
ID: int(partDTO.ID),
UserID: int(partDTO.UserID),
EventID: int(partDTO.EventID),
InvitedByUserID: int(partDTO.InvitedByUserID.Int32),
CreatedAt: partDTO.CreatedAt,
UpdatedAt: partDTO.UpdatedAt,
}, nil
}
// InsertParticipation implements repo.EventRepository.
func (e *eventRepository) InsertParticipation(
ctx context.Context,
userID int,
eventID int,
invitedByUserID int,
tx any,
) (*model.ParticipationEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
var invitedBy sql.NullInt32
if invitedByUserID == 0 {
invitedBy = sql.NullInt32{Int32: 0, Valid: false}
} else {
invitedBy = sql.NullInt32{Int32: int32(invitedByUserID), Valid: true}
}
participationDTO, err := queries.InsertParticipation(timeoutCtx, sqlc.InsertParticipationParams{
UserID: int32(userID),
EventID: int32(eventID),
InvitedByUserID: invitedBy,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err != nil {
return nil, err
}
return &model.ParticipationEntity{
ID: int(participationDTO.ID),
UserID: int(participationDTO.UserID),
EventID: int(participationDTO.EventID),
InvitedByUserID: int(participationDTO.InvitedByUserID.Int32),
CreatedAt: participationDTO.CreatedAt,
UpdatedAt: participationDTO.UpdatedAt,
}, nil
}

View File

@ -0,0 +1,122 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
import (
"database/sql"
"encoding/json"
"testing"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/stretchr/testify/assert"
)
func TestConvToEventRetrieved(t *testing.T) {
input := &sqlc.GetEventByIDRow{
ID: 123,
Name: "event",
Description: sql.NullString{Valid: false},
TotalAmount: sql.NullInt32{Valid: false},
DefaultCurrency: "EUR",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
Users: json.RawMessage(`[{"id":1, "first_name":"owner", "last_name":"owner"}]`),
}
want := &model.EventRetrieved{
ID: 123,
Name: "event",
Description: "",
TotalAmount: model.Money{Amount: 0, Currency: "EUR"},
DefaultCurrency: model.Currency("EUR"),
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
Users: []model.UserBaseRetrieved{
{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
}
got, err := convToEventRetrieved(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}
func TestConvToEventList(t *testing.T) {
input := []sqlc.ListEventsByUserIDRow{
{
ID: 123,
Name: "event",
Description: sql.NullString{Valid: false},
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
},
{
ID: 456,
Name: "event2",
Description: sql.NullString{String: "super event", Valid: true},
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: json.RawMessage(`{"id":1, "first_name":"owner", "last_name":"owner"}`),
},
}
want := []model.EventListRetrieved{
{
ID: 123,
Name: "event",
Description: "",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
{
ID: 456,
Name: "event2",
Description: "super event",
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Owner: &model.UserBaseRetrieved{
ID: 1,
FirstName: "owner",
LastName: "owner",
},
},
}
got, err := convToEventList(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}

View File

@ -0,0 +1,267 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
)
type expenseRepository struct {
queries *sqlc.Queries
}
func NewExpenseRepository(db *sql.DB) repo.ExpenseRepository {
return &expenseRepository{
queries: sqlc.New(db),
}
}
// DeleteExpense implements repo.ExpenseRepository.
func (e *expenseRepository) DeleteExpense(ctx context.Context, expenseID int, tx any) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
return queries.DeleteExpense(timeoutCtx, int32(expenseID))
}
// DeleteTransactionsOfExpense implements repo.ExpenseRepository.
func (e *expenseRepository) DeleteTransactionsOfExpense(
ctx context.Context,
expenseID int,
tx any,
) error {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
return queries.DeleteTransactionsOfExpenseID(timeoutCtx, int32(expenseID))
}
// GetExpenseByID implements repo.ExpenseRepository.
func (e *expenseRepository) GetExpenseByID(
ctx context.Context,
expenseID int,
tx any,
) (*model.ExpenseRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.GetExpenseByID(timeoutCtx, int32(expenseID))
if err != nil {
return nil, err
}
expense, err := convToExpenseRetrieved(&expenseDTO)
if err != nil {
return nil, err
}
return expense, nil
}
func convToPayments(raw json.RawMessage) ([]model.Payment, error) {
var paymentsRetrieved []model.PaymentRetrieved
err := json.Unmarshal(raw, &paymentsRetrieved)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
payments := make([]model.Payment, len(paymentsRetrieved))
for i, p := range paymentsRetrieved {
payment := model.Payment{
PayerID: p.PayerID,
PayerFirstName: p.PayerFirstName,
PayerLastName: p.PayerLastName,
Amount: model.MakeMoney(p.Amount, model.Currency(p.Currency)),
}
payments[i] = payment
}
return payments, nil
}
func convToBenefits(raw json.RawMessage) ([]model.Benefit, error) {
var benefitsRetrieved []model.BenefitRetrieved
err := json.Unmarshal(raw, &benefitsRetrieved)
if err != nil {
// Unexpected
log.ErrorLog("json unmarshal error", "err", err)
return nil, err
}
benefits := make([]model.Benefit, len(benefitsRetrieved))
for i, b := range benefitsRetrieved {
benefit := model.Benefit{
RecipientID: b.RecipientID,
RecipientFirstName: b.RecipientFirstName,
RecipientLastName: b.RecipientLastName,
Amount: model.MakeMoney(b.Amount, model.Currency(b.Currency)),
}
benefits[i] = benefit
}
return benefits, nil
}
func convToExpenseRetrieved(expenseDTO *sqlc.GetExpenseByIDRow) (*model.ExpenseRetrieved, error) {
payments, err := convToPayments(expenseDTO.Payments)
if err != nil {
// Unexpected
return nil, err
}
benefits, err := convToBenefits(expenseDTO.Benefits)
if err != nil {
// Unexpected
return nil, err
}
expenseRetrieved := &model.ExpenseRetrieved{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.UpdatedAt,
Amount: model.MakeMoney(int(expenseDTO.Amount), model.Currency(expenseDTO.Currency)),
EventID: int(expenseDTO.EventID),
Detail: model.ExpenseDetail{
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
},
Payments: payments,
Benefits: benefits,
}
return expenseRetrieved, nil
}
// InsertExpense implements repo.ExpenseRepository.
func (e *expenseRepository) InsertExpense(
ctx context.Context,
expenseEntity *model.ExpenseEntity,
tx any,
) (*model.ExpenseEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.InsertExpense(timeoutCtx, sqlc.InsertExpenseParams{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Amount: int32(expenseEntity.Amount),
Currency: expenseEntity.Currency,
EventID: int32(expenseEntity.EventID),
Name: sql.NullString{String: expenseEntity.Name, Valid: true},
Place: sql.NullString{String: expenseEntity.Place, Valid: true},
})
if err != nil {
return nil, err
}
return &model.ExpenseEntity{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.CreatedAt,
Amount: int(expenseDTO.Amount),
Currency: expenseDTO.Currency,
EventID: int(expenseDTO.EventID),
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
}, nil
}
// ListExpensesByEventID implements repo.ExpenseRepository.
func (e *expenseRepository) ListExpensesByEventID(
ctx context.Context,
id int,
tx any,
) ([]model.ExpensesListRetrieved, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
listDTO, err := queries.ListExpensesByEventID(timeoutCtx, int32(id))
if err != nil {
return nil, err
}
res := make([]model.ExpensesListRetrieved, len(listDTO))
for i, dto := range listDTO {
elem := model.ExpensesListRetrieved{
ID: int(dto.ID),
CreatedAt: dto.CreatedAt,
UpdatedAt: dto.UpdatedAt,
Amount: model.MakeMoney(int(dto.Amount), model.Currency(dto.Currency)),
EventID: int(dto.EventID),
Detail: model.ExpenseDetail{
Name: dto.Name.String,
Place: dto.Place.String,
},
}
res[i] = elem
}
return res, nil
}
// UpdateExpenseByID implements repo.ExpenseRepository.
func (e *expenseRepository) UpdateExpenseByID(
ctx context.Context,
expenseUpdate *model.ExpenseUpdateEntity,
tx any,
) (*model.ExpenseEntity, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
queries := getQueries(e.queries, tx)
expenseDTO, err := queries.UpdateExpenseByID(timeoutCtx, sqlc.UpdateExpenseByIDParams{
ID: int32(expenseUpdate.ID),
UpdatedAt: time.Now(),
Amount: int32(expenseUpdate.Amount),
Currency: expenseUpdate.Currency,
Name: sql.NullString{String: expenseUpdate.Name, Valid: true},
Place: sql.NullString{String: expenseUpdate.Place, Valid: true},
})
if err != nil {
return nil, err
}
return &model.ExpenseEntity{
ID: int(expenseDTO.ID),
CreatedAt: expenseDTO.CreatedAt,
UpdatedAt: expenseDTO.CreatedAt,
Amount: int(expenseDTO.Amount),
Currency: expenseDTO.Currency,
EventID: int(expenseDTO.EventID),
Name: expenseDTO.Name.String,
Place: expenseDTO.Place.String,
}, nil
}

View File

@ -0,0 +1,96 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
import (
"database/sql"
"encoding/json"
"testing"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"github.com/stretchr/testify/assert"
)
func TestConvToExpenseRetrieved(t *testing.T) {
input := &sqlc.GetExpenseByIDRow{
ID: 123,
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Amount: 123,
Currency: "EUR",
EventID: 123,
Name: sql.NullString{Valid: false},
Place: sql.NullString{Valid: false},
Payments: json.RawMessage(
`[{"payer_id": 1, "payer_first_name": "toto", "payer_last_name": "titi", "amount": 10, "currency": "EUR"},
{"payer_id": 2, "payer_first_name": "tata", "payer_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
),
Benefits: json.RawMessage(
`[{"recipient_id": 1, "recipient_first_name": "toto", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"},
{"recipient_id": 2, "recipient_first_name": "tata", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
),
}
want := &model.ExpenseRetrieved{
ID: 123,
CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
Amount: model.Money{Amount: 123, Currency: model.Currency("EUR")},
EventID: 123,
Detail: model.ExpenseDetail{},
Payments: []model.Payment{
{
PayerID: 1,
PayerFirstName: "toto",
PayerLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
{
PayerID: 2,
PayerFirstName: "tata",
PayerLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
},
Benefits: []model.Benefit{
{
RecipientID: 1,
RecipientFirstName: "toto",
RecipientLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
{
RecipientID: 2,
RecipientFirstName: "tata",
RecipientLastName: "titi",
Amount: model.Money{Amount: 10, Currency: model.Currency("EUR")},
},
},
}
got, err := convToExpenseRetrieved(input)
assert.NoError(t, err)
assert.Equal(t, want, got)
}

View File

@ -0,0 +1,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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
-- 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;

View 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
}

View 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
}

View File

@ -40,8 +40,25 @@ const (
configType = "yaml"
)
func defaultConfig() {
viper.SetDefault("dev-mode", true)
// web
viper.SetDefault("web.addr", ":8000")
viper.SetDefault("web.shutdown-timeout", "10")
// db
viper.SetDefault("db.host", "localhost")
viper.SetDefault("db.port", 5432)
viper.SetDefault("db.username", "postgres")
viper.SetDefault("db.password", "example")
viper.SetDefault("db.database", "howmuch")
viper.SetDefault("db.sslmode", "disable")
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
defaultConfig()
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
@ -65,7 +82,7 @@ func initConfig() {
viper.SetEnvKeyReplacer(replacer)
if err := viper.ReadInConfig(); err != nil {
log.ErrorLog("Failed to read viper configuration file", "err", err)
log.ErrorLog("Failed to read viper configuration file, use default config", "err", err)
return
}

View File

@ -31,13 +31,14 @@ import (
"syscall"
"time"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/infra/datastore"
"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/middleware"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
"git.vinchent.xyz/vinchent/howmuch/pkg/version/verflag"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/net/context"
@ -97,6 +98,7 @@ to share their expense of an event or a trip`,
}
func run() error {
// Set Gin running mode
isDev := viper.GetBool("dev-mode")
if isDev {
gin.SetMode(gin.DebugMode)
@ -104,32 +106,53 @@ func run() error {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
// Init DB
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"),
)
// 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"),
}
dbConn := datastore.NewDB(dbConfString, dbExtraConf)
if dbConn == nil {
log.FatalLog("DB connection failure")
}
defer dbConn.Close()
// Middlewares
corsCfg := cors.DefaultConfig()
corsCfg.AllowAllOrigins = true
corsCfg.AllowHeaders = append(corsCfg.AllowHeaders, "Authorization", "Accept", "X-CSRF-Token")
r.Use(cors.New(corsCfg))
r.Use(middleware.RequestID())
r.NoRoute(func(ctx *gin.Context) {
core.WriteResponse(ctx, errno.PageNotFoundErr, nil)
// Init Cache
cache := datastore.NewCache(&redis.Options{
Addr: viper.GetString("cache.host"),
Password: viper.GetString("cache.password"),
DB: 0,
})
defer cache.Close()
r.GET("/", func(ctx *gin.Context) {
// time.Sleep(10 * time.Second) // Test shutdown
core.WriteResponse(ctx, nil, gin.H{
"message": "how much?",
})
})
// Init token
token.Init(viper.GetString("web.token-secret"), viper.GetDuration("web.token-expiry-time"))
// Register the core service
r := registry.NewRegistry(dbConn, cache)
engine := gin.Default()
engine = router.Routes(engine, r.NewAppController(), cache)
server := http.Server{
Addr: viper.GetString("web.addr"),
Handler: r,
Handler: engine,
}
log.InfoLog("Server running", "port", viper.GetString("web.addr"))
go func() {
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.FatalLog(err.Error())

View 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()
}

View File

@ -0,0 +1,87 @@
// 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 (
"database/sql"
"time"
"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, 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
}
if err := db.Ping(); err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,84 @@
// 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 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"
)
// 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()
corsCfg.AllowAllOrigins = true
corsCfg.AllowHeaders = append(corsCfg.AllowHeaders, "Authorization", "Accept", "X-CSRF-Token")
engine.Use(cors.New(corsCfg))
// Use my request id middleware
// TODO: I might use the community version later
engine.Use(middleware.RequestID())
// Route for the 404 error
engine.NoRoute(func(ctx *gin.Context) {
core.WriteResponse(ctx, errno.PageNotFoundErr, nil)
})
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
}

View 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
}
// }}}

View 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"`
}
// }}}

View 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
}

View File

@ -0,0 +1,39 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package model
import "time"
type ParticipationEntity Participation
// Participation is the association between Users and Events
type Participation struct {
ID int
UserID int
EventID int
InvitedByUserID int
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@ -0,0 +1,48 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package model
import "time"
// {{{ Entity
type TransactionEntity Transaction
// }}}
// {{{ Domain object
type Transaction struct {
ID int
ExpenseID int
UserID int
Amount int
Currency string
IsIncome bool // To note that the direction of the money (payment or income)
CreatedAt time.Time
UpdatedAt time.Time
}
// }}}
// Transaction is the association between Expenses and Users

View File

@ -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
}
// }}}

View File

@ -0,0 +1,30 @@
// 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"
// NewUserController returns an admin controller's implementation
func (r *registry) NewAdminController() controller.Admin {
return &controller.AdminController{}
}

View File

@ -0,0 +1,61 @@
// 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 (
"database/sql"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
)
// registry is an implementation of Registry interface.
// It needs a db connection to provide the necessary support.
// It might holds other drivers when the projects grows. For example
// the object needed to connect to Redis or Kafka.
type registry struct {
db *sql.DB
cache core.Cache
}
// Registry returns a new app controller that will be used by main()/run()
// AppController is essentially the struct that holds all the controllers,
// classed by their domains.
type Registry interface {
NewAppController() controller.AppController
}
// NewRegistry returns a new Registry's implementation.
func NewRegistry(db *sql.DB, cache core.Cache) Registry {
return &registry{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(),
Session: r.NewSessionController(),
}
}

View 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)
}

View 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"
)
// NewUserController returns a user controller's implementation
func (r *registry) NewUserController() controller.User {
u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
return controller.NewUserController(u)
}

View 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)
}

View File

@ -0,0 +1,74 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package repo
import (
"context"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
)
type EventRepository interface {
Create(ctx context.Context, evEntity *model.EventEntity, tx any) (*model.EventEntity, error)
// UpdateEventByID updates the event related information (name, descriptions)
UpdateEventByID(ctx context.Context, event *model.EventUpdateEntity, tx any) error
GetByID(ctx context.Context, eventID int, tx any) (*model.EventRetrieved, error)
// related to events of a user
ListEventsByUserID(ctx context.Context, userID int, tx any) ([]model.EventListRetrieved, error)
InsertParticipation(
ctx context.Context,
userID, eventID, invitedByUserID int,
tx any,
) (*model.ParticipationEntity, error)
GetParticipation(
ctx context.Context,
userID, eventID int,
tx any,
) (*model.ParticipationEntity, error)
}
type ExpenseRepository interface {
DeleteExpense(ctx context.Context, expenseID int, tx any) error
DeleteTransactionsOfExpense(ctx context.Context, expenseID int, tx any) error
GetExpenseByID(ctx context.Context, expenseID int, tx any) (*model.ExpenseRetrieved, error)
InsertExpense(
ctx context.Context,
expenseEntity *model.ExpenseEntity,
tx any,
) (*model.ExpenseEntity, error)
ListExpensesByEventID(
ctx context.Context,
id int,
tx any,
) ([]model.ExpensesListRetrieved, error)
UpdateExpenseByID(
ctx context.Context,
expenseUpdate *model.ExpenseUpdateEntity,
tx any,
) (*model.ExpenseEntity, error)
}

View File

@ -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)
}

View File

@ -0,0 +1,190 @@
// MIT License
//
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package usecase
import (
"net/http"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
"golang.org/x/net/context"
)
type eventUsecase struct {
userUC User
eventRepo repo.EventRepository
expenseRepo repo.ExpenseRepository
dbRepo repo.DBRepository
}
var ErrNoParticipation = &errno.Errno{
HTTP: http.StatusUnauthorized,
Code: errno.ErrorCode(errno.AuthFailureCode, "NoParticipation"),
Message: "user doesn't have access to this event",
}
// For the controller
type Event interface{}
func NewEventUsecase(
uuc User,
ev repo.EventRepository,
ex repo.ExpenseRepository,
db repo.DBRepository,
) Event {
return &eventUsecase{uuc, ev, ex, db}
}
func (evuc *eventUsecase) CreateEvent(
ctx context.Context,
evRequest *model.EventCreateRequest,
) (*model.EventInfoResponse, error) {
// transfer evRequest to evEntity
evEntity := &model.EventEntity{
Name: evRequest.Name,
Description: evRequest.Description,
OwnerID: evRequest.OwnerID,
TotalAmount: 0,
DefaultCurrency: string(evRequest.DefaultCurrency),
}
data, err := evuc.dbRepo.Transaction(
ctx,
func(txCtx context.Context, tx any) (any, error) {
// Create the event
created, err := evuc.eventRepo.Create(ctx, evEntity, tx)
if err != nil {
return nil, err
}
// participate to the event
participation, err := evuc.eventRepo.InsertParticipation(
ctx,
created.OwnerID,
created.ID,
0,
tx,
)
if err != nil {
return nil, err
}
if participation == nil {
// Unexpected error
log.ErrorLog(
"participation existed for event-user pair",
"userID",
created.OwnerID,
"eventID",
created.ID,
)
return nil, errno.InternalServerErr
}
// TODO: App log, maybe can be sent to some third party service.
log.InfoLog(
"created new event",
"name",
created.Name,
"owner",
created.OwnerID,
)
// Construct the response
ownerResponse, err := evuc.userUC.GetUserBaseResponseByID(ctx, created.OwnerID)
if err != nil {
return nil, err
}
evResponse := &model.EventInfoResponse{
ID: created.ID,
Name: created.Name,
Description: created.Description,
TotalAmount: model.MakeMoney(
created.TotalAmount,
model.Currency(created.DefaultCurrency),
),
Owner: ownerResponse,
CreatedAt: created.CreatedAt,
UpdatedAt: created.UpdatedAt,
Users: []model.UserBaseResponse{*ownerResponse},
}
return evResponse, err
},
)
if err != nil {
return nil, err
}
res := data.(*model.EventInfoResponse)
return res, err
}
func (evuc *eventUsecase) ListEvents(
ctx context.Context,
userID int,
) ([]model.EventListResponse, error) {
eventListRetrieved, err := evuc.eventRepo.ListEventsByUserID(ctx, userID, nil)
if err != nil {
return nil, err
}
// Check if the user is a member of the event
responses := make([]model.EventListResponse, len(eventListRetrieved))
for i, retrieved := range eventListRetrieved {
ownner := model.UserBaseResponse(*retrieved.Owner)
res := model.EventListResponse{
ID: retrieved.ID,
Name: retrieved.Name,
Description: retrieved.Description,
Owner: &ownner,
CreatedAt: retrieved.CreatedAt,
}
responses[i] = res
}
return responses, nil
}
// GetEventDetail
func (evuc *eventUsecase) GetEventDetail(
ctx context.Context,
userID, eventID int,
) (*model.EventInfoResponse, error) {
// Check if the user has the right to get this event
// err := evuc.participationRepo.CheckParticipation(ctx, userID, eventID)
// if err != nil {
// return nil, ErrNoParticipation
// }
// Get the eventDetail
// TODO: This can also be put into the cache
return nil, nil
}

View File

@ -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)
}

View 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
}

View 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
}

View 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)
}
}

View 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
}

View File

@ -1,5 +1,40 @@
// 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 "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)
}

View File

@ -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",
}
)

View File

@ -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

View File

@ -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...)
}

View 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()
}
}

View 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)
})
}

View File

@ -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()
}
}

View File

@ -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)
}

View 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"
)

View 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
View 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)
}

View File

@ -0,0 +1,2 @@
ALTER TABLE "admin"
ADD CONSTRAINT unique_email UNIQUE ("email");

View File

@ -0,0 +1 @@
DROP TABLE "event"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

31
sqlc.yml Normal file
View File

@ -0,0 +1,31 @@
# 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.
version: "2"
sql:
- engine: "postgresql"
queries: "internal/howmuch/adapter/repo/sqlc"
schema: "migrations"
gen:
go:
out: "internal/howmuch/adapter/repo/sqlc"
emit_interface: true

View File

@ -1 +0,0 @@
exit status 2exit status 2exit status 2exit status 2exit status 2

15
web/.eslintrc.cjs Normal file
View 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
View 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

Some files were not shown because too many files have changed in this diff Show More