Compare commits
	
		
			1 Commits
		
	
	
		
			main
			...
			6f9ff9ab96
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 6f9ff9ab96 | 
@ -7,7 +7,7 @@ tmp_dir = "tmp"
 | 
			
		||||
  bin = "./_output/howmuch"
 | 
			
		||||
  cmd = "make build"
 | 
			
		||||
  delay = 1000
 | 
			
		||||
  exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output", "internal/howmuch/adapter/repo/sqlc"]
 | 
			
		||||
  exclude_dir = ["assets", "tmp", "vendor", "testdata", "_output"]
 | 
			
		||||
  exclude_file = []
 | 
			
		||||
  exclude_regex = ["_test.go"]
 | 
			
		||||
  exclude_unchanged = false
 | 
			
		||||
 | 
			
		||||
@ -1,63 +0,0 @@
 | 
			
		||||
# 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 }}."
 | 
			
		||||
							
								
								
									
										35
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -24,38 +24,5 @@ go.work.sum
 | 
			
		||||
 | 
			
		||||
# Custom
 | 
			
		||||
/_output
 | 
			
		||||
/deployment/tmp
 | 
			
		||||
/deployment/db_data
 | 
			
		||||
/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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								Makefile
									
									
									
									
									
								
							@ -29,15 +29,14 @@ GO_LDFLAGS += \
 | 
			
		||||
# ==============================================================================
 | 
			
		||||
.PHONY: all
 | 
			
		||||
all: add-copyright format build
 | 
			
		||||
web: web-all
 | 
			
		||||
 | 
			
		||||
# ==============================================================================
 | 
			
		||||
 | 
			
		||||
.PHONY: build
 | 
			
		||||
build: tidy sqlc # build.
 | 
			
		||||
	@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go
 | 
			
		||||
	@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/howmuch $(ROOT_DIR)/cmd/howmuch/main.go 2>/dev/null
 | 
			
		||||
 | 
			
		||||
.PHONY: sqlc
 | 
			
		||||
sqlc:
 | 
			
		||||
	@sqlc generate
 | 
			
		||||
 | 
			
		||||
.PHONY: format
 | 
			
		||||
@ -46,7 +45,7 @@ format: # format code.
 | 
			
		||||
 | 
			
		||||
.PHONY: add-copyright
 | 
			
		||||
add-copyright: # add license to file headers.
 | 
			
		||||
	@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc,web,mock
 | 
			
		||||
	@addlicense -v -f $(ROOT_DIR)/LICENSE $(ROOT_DIR) --skip-files=database.yml --skip-dirs=$(OUTPUT_DIR),deployment,migrations,configs,sqlc
 | 
			
		||||
 | 
			
		||||
.PHONY: swagger
 | 
			
		||||
swagger: # Run swagger.
 | 
			
		||||
@ -56,22 +55,6 @@ 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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										385
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										385
									
								
								README.md
									
									
									
									
									
								
							@ -13,19 +13,6 @@
 | 
			
		||||
      - [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
 | 
			
		||||
@ -36,8 +23,6 @@ It is a personal project to learn go and relative technologies.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Project Diary
 | 
			
		||||
 | 
			
		||||
### 2024/09/30
 | 
			
		||||
@ -61,11 +46,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.
 | 
			
		||||
@ -109,10 +94,10 @@ The execution of the program is then just a command like `howmuch run`.
 | 
			
		||||
Moreover, in a distributed system, configs can be stored on `etcd`.
 | 
			
		||||
 | 
			
		||||
> [Kubernetes stores configuration data into etcd for service discovery and
 | 
			
		||||
> cluster management; etcd’s consistency is crucial for correctly scheduling
 | 
			
		||||
> and operating services. The Kubernetes API server persists cluster state
 | 
			
		||||
> into etcd. It uses etcd’s watch API to monitor the cluster and roll out
 | 
			
		||||
> critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
 | 
			
		||||
cluster management; etcd’s consistency is crucial for correctly scheduling
 | 
			
		||||
and operating services. The Kubernetes API server persists cluster state
 | 
			
		||||
into etcd. It uses etcd’s watch API to monitor the cluster and roll out
 | 
			
		||||
critical configuration changes.](https://etcd.io/docs/v3.5/learning/why/)
 | 
			
		||||
 | 
			
		||||
#### Business logic
 | 
			
		||||
 | 
			
		||||
@ -120,8 +105,8 @@ Moreover, in a distributed system, configs can be stored on `etcd`.
 | 
			
		||||
- 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...
 | 
			
		||||
@ -173,26 +158,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.
 | 
			
		||||
 | 
			
		||||
@ -226,17 +211,17 @@ For the test-driven part,
 | 
			
		||||
- 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
 | 
			
		||||
    - 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
 | 
			
		||||
    - 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.
 | 
			
		||||
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.
 | 
			
		||||
@ -256,329 +241,3 @@ rest, I just put unit tests aside for later.
 | 
			
		||||
That should be the correct workflow. But to save time, I will cut off the
 | 
			
		||||
integration test part (the 2nd point).
 | 
			
		||||
 | 
			
		||||
### 2024/10/07
 | 
			
		||||
 | 
			
		||||
I rethought about the whole API design (even though I have only one yet). I
 | 
			
		||||
have created `/signup` and `/login` without thinking too much, but in fact
 | 
			
		||||
it is not quite _RESTful_.
 | 
			
		||||
 | 
			
		||||
**REST** is all about resources. While `/signup` and `/login` is quite
 | 
			
		||||
comprehensible, thus service-oriented, they don't follow the REST philosophy,
 | 
			
		||||
that is to say, **resource-oriented**.
 | 
			
		||||
 | 
			
		||||
If we rethink about `/signup`, what it does is to create a resource of `User`.
 | 
			
		||||
Thus, for a backend API, it'd better be named as `User.Create`. But what
 | 
			
		||||
about `/login`, it doesn't do anything about `User`. It would be strange to
 | 
			
		||||
declare it as a User-relevant method.
 | 
			
		||||
 | 
			
		||||
Instead, what `/login` really does, is to **create a session**.
 | 
			
		||||
In consequence, we have to create a new struct `Session` that can be created,
 | 
			
		||||
deleted, or updated.
 | 
			
		||||
 | 
			
		||||
It might seem overkill, and in real life, even in the official Pet store
 | 
			
		||||
example of OpenAPI, signup and login are under /user. But it just opened my
 | 
			
		||||
mind and forces me to **think and design RESTfully**!
 | 
			
		||||
 | 
			
		||||
That being said, for the user side, we shall still have `/signup` and `/login`,
 | 
			
		||||
because on the Front-end, we must be user-centered. We can even make this
 | 
			
		||||
2 functions on the same page with the same endpoint `/login`. The user enter
 | 
			
		||||
the email and the password, then clicks on `Login or Signup`. If the login
 | 
			
		||||
is successful, then he is logged in. Otherwise, if the user doesn't exist
 | 
			
		||||
yet, we open up 2 more inputs (first name and last name) for signup. They
 | 
			
		||||
can just provide the extra information and click again on `Signup`.
 | 
			
		||||
 | 
			
		||||
That, again, being said, I am thinking about doing some Front-end stuff just
 | 
			
		||||
to make the validation tests of the product simpler.
 | 
			
		||||
 | 
			
		||||
#### The choice of the front end framework
 | 
			
		||||
 | 
			
		||||
I have considered several choices.
 | 
			
		||||
 | 
			
		||||
If I didn't purposely make the backend code to provide a REST API, I might
 | 
			
		||||
choose server-side-rendering with `templ + htmx`, or even `template+vanilla
 | 
			
		||||
javascript`.
 | 
			
		||||
 | 
			
		||||
I can still write a rather static Go-frontend-server to serve HTMLs and call
 | 
			
		||||
my Go backend. _And it might be a good idea if they communicate on Go native
 | 
			
		||||
rpc._ It worth a try.
 | 
			
		||||
 | 
			
		||||
And I have moved on to `Svelte` which seems very simple by design and the
 | 
			
		||||
whole compile thing makes it really charm. But this is mainly a Go project,
 | 
			
		||||
to learn something new with a rather small community means potentially more
 | 
			
		||||
investment. I can learn it later.
 | 
			
		||||
 | 
			
		||||
Among `Angular`, `React` and `Vue`, I prefer `Vue`, for several reasons.
 | 
			
		||||
First, `Angular` is clearly overkill for this small demo project. Second,
 | 
			
		||||
`React` is good but I personally like the way of Vue doing things. And I
 | 
			
		||||
work with Vue at work, so I might have more technical help from my colleagues.
 | 
			
		||||
 | 
			
		||||
So the plan for this week is to have both the Front end part and Backend part
 | 
			
		||||
working, just for user signup and login.
 | 
			
		||||
 | 
			
		||||
I would like to directly put this stuff on a CI-pipeline for tests and
 | 
			
		||||
deployment, even I have barely nothing yet. It is always good to do this
 | 
			
		||||
preparation stuff at the early stage of the project. So we can benefit from
 | 
			
		||||
them all the way along.
 | 
			
		||||
 | 
			
		||||
Moreover, even I am not really finishing the project, it can still be
 | 
			
		||||
something representable that I can show to a future interviewer.
 | 
			
		||||
 | 
			
		||||
### 2024/10/08
 | 
			
		||||
 | 
			
		||||
Gitea action setup ! 🎉🎉🎉
 | 
			
		||||
 | 
			
		||||
Next step is to run some check and build and test!
 | 
			
		||||
 | 
			
		||||
### 2024/10/09
 | 
			
		||||
 | 
			
		||||
No code for today neither. But I did some design for the user story and
 | 
			
		||||
the database model design.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
### 2024/10/11
 | 
			
		||||
 | 
			
		||||
I spent 2 days learning some basic of Vue. Learning Vue takes time. There
 | 
			
		||||
are a lot of concepts and it needs a lot of practice. Even though I may not
 | 
			
		||||
need a professional level web page, I don't want to copy one module from this
 | 
			
		||||
blog and another one from another tutorial. I might just put aside the
 | 
			
		||||
front-end for now and concentrate on my backend Go app.
 | 
			
		||||
 | 
			
		||||
For now, I will just test my backend with `curl`.
 | 
			
		||||
 | 
			
		||||
And today's job is to get the login part done!
 | 
			
		||||
 | 
			
		||||
### 2024/10/13
 | 
			
		||||
 | 
			
		||||
Finally it took more than just one night for me to figure out the JWT.
 | 
			
		||||
 | 
			
		||||
The JWT token is simple because it doesn't need to be stored to and fetched
 | 
			
		||||
from a database. But there is no way to revoke it instead of waiting for the
 | 
			
		||||
expiry date.
 | 
			
		||||
 | 
			
		||||
To do so, we still have to use a database. We can store a logged out user's
 | 
			
		||||
jti into Redis, and each time we log in, look up the cache to find if the
 | 
			
		||||
user is logged out. And set the cache's timeout to the expiry time of the
 | 
			
		||||
token, so that it is removed automatically.
 | 
			
		||||
 | 
			
		||||
It'd better to inject the dependency of Redis connection into the `Authn`
 | 
			
		||||
middleware so that it's simpler to test.
 | 
			
		||||
 | 
			
		||||
### 2024/10/15
 | 
			
		||||
 | 
			
		||||
Redis is integrated to keep a blacklist of logged out users. BTW `memcached`
 | 
			
		||||
is also interesting. In case later I want to switch to another key-value
 | 
			
		||||
storage, I have made an interface. It also helps for the test. I can even
 | 
			
		||||
just drop the redis and use a bare-hand native hashmap.
 | 
			
		||||
 | 
			
		||||
Quite a lot benefits. And then I realised that I have done "wrong" about
 | 
			
		||||
`sqlc`. I shouldn't have used the pgx driver, instead the `database/sql`
 | 
			
		||||
driver is more universal, if I want to switch to sqlite or mysql later.
 | 
			
		||||
 | 
			
		||||
Well it's not about changing the technical solution every 3 days, but a
 | 
			
		||||
system than can survive those changes elegantly must be a robust system, with
 | 
			
		||||
functionalities well decoupled and interfaces well defined.
 | 
			
		||||
 | 
			
		||||
I will add some tests for existing code and then it's time to move on to
 | 
			
		||||
my core business logic.
 | 
			
		||||
 | 
			
		||||
### 2024/10/16
 | 
			
		||||
 | 
			
		||||
I am facing a design problem. My way to implement the business logic is to
 | 
			
		||||
first write the core logic code in the domain service level. It will help me
 | 
			
		||||
to identify if there are any missing part in my model design. Thus, when
 | 
			
		||||
some of the business logic is done, I can create database migrations and then
 | 
			
		||||
implement the adapter level's code.
 | 
			
		||||
 | 
			
		||||
The problem is that my design depends heavily on the database. Taking the
 | 
			
		||||
example of adding an expense to en event.
 | 
			
		||||
 | 
			
		||||
Input is a valid `ExpenseDTO` which has the `event`, `paiements` and
 | 
			
		||||
`receptions`. What I must do is to open a database transaction where I:
 | 
			
		||||
 | 
			
		||||
1. Get the Event. (Most importantly the `TotalAmount`)
 | 
			
		||||
2. For each `paiemnt` and `reception` create a transaction related to the
 | 
			
		||||
`User`. And insert them into the database.
 | 
			
		||||
3. Update the `TotalAmount`
 | 
			
		||||
4. Update the caches if any
 | 
			
		||||
 | 
			
		||||
If any step fails, the transaction rolls back.
 | 
			
		||||
 | 
			
		||||
This has barely no logic at all. I think it is not suitable to try to tie
 | 
			
		||||
this operation to the domain model.
 | 
			
		||||
 | 
			
		||||
However, there is something that worth a domain model level method, that
 | 
			
		||||
is to calculate the share of each members of the event, where we will have
 | 
			
		||||
the list of members and the amount of balance they have. And then we will
 | 
			
		||||
do the calculate and send back a list of money one should pay for another.
 | 
			
		||||
 | 
			
		||||
Finally, I think the business logic is still too simple to be put into a
 | 
			
		||||
"Domain". For now, the service layer is just enough.
 | 
			
		||||
 | 
			
		||||
### 2024/10/17
 | 
			
		||||
 | 
			
		||||
The following basic use cases are to be implemented at the first time.
 | 
			
		||||
 | 
			
		||||
- [X] A user signs up
 | 
			
		||||
- [X] A user logs in
 | 
			
		||||
- [ ] A user lists their events (pagination)
 | 
			
		||||
- [ ] A user sees the detail of an event (description, members, amount)
 | 
			
		||||
- [ ] A user sees the expenses of an event (total amount, personal expenses, pagination)
 | 
			
		||||
- [ ] A user sees the detail of an expense: (time, amount, payers, recipients)
 | 
			
		||||
- [ ] A user adds an expense
 | 
			
		||||
- [ ] A user updates/changes an expense (may handle some extra access control)
 | 
			
		||||
- [ ] A user can pay the debt to other members (just a special case of expense)
 | 
			
		||||
- [ ] A user creates an event (and participate to it)
 | 
			
		||||
- [ ] A user updates the event info
 | 
			
		||||
- [ ] A user invites another user by sending a mail with a token.
 | 
			
		||||
- [ ] A user joins an event by accepting an invitation
 | 
			
		||||
- [ ] A user cannot see other user's information
 | 
			
		||||
- [ ] A user cannot see the events that they didn't participated in.
 | 
			
		||||
 | 
			
		||||
For the second stage:
 | 
			
		||||
 | 
			
		||||
- [ ] A user can archive an event
 | 
			
		||||
- [ ] A user deletes an expense (may handle some extra access control)
 | 
			
		||||
- [ ] A user restore a deleted expense
 | 
			
		||||
- [ ] Audit log for expense updates/deletes
 | 
			
		||||
- [ ] ~A user quits an event (they cannot actually, but we can make as if they
 | 
			
		||||
quitted)~ **No we can't quit!**
 | 
			
		||||
 | 
			
		||||
With those functionalities, there will be an usable product. And then we can
 | 
			
		||||
work on other aspects. For example:
 | 
			
		||||
 | 
			
		||||
- introduce an admin to handle users.
 | 
			
		||||
- user info updates
 | 
			
		||||
- deleting user
 | 
			
		||||
- More user related contents
 | 
			
		||||
- Event related contents
 | 
			
		||||
- ex. Trip journal...
 | 
			
		||||
 | 
			
		||||
Stop dreaming... Just do the simple stuff first!
 | 
			
		||||
 | 
			
		||||
### 2024/10/18
 | 
			
		||||
 | 
			
		||||
I spent some time to figure out this one! But I don't actually need it for now.
 | 
			
		||||
So I just keep it here:
 | 
			
		||||
 | 
			
		||||
```SQL
 | 
			
		||||
SELECT
 | 
			
		||||
    e.id,
 | 
			
		||||
    e.name,
 | 
			
		||||
    e.description,
 | 
			
		||||
    e.created_at,
 | 
			
		||||
    json_build_object(
 | 
			
		||||
        'id', o.id,
 | 
			
		||||
        'first_name', o.first_name,
 | 
			
		||||
        'last_name', o.last_name
 | 
			
		||||
    ) AS owner,
 | 
			
		||||
    json_agg(
 | 
			
		||||
        json_build_object(
 | 
			
		||||
            'id', u.id,
 | 
			
		||||
            'first_name', u.first_name,
 | 
			
		||||
            'last_name', u.last_name
 | 
			
		||||
        )
 | 
			
		||||
    ) AS users  -- Aggregation for users in the event
 | 
			
		||||
FROM "event" e
 | 
			
		||||
JOIN "participation" p ON p.event_id = e.id  -- participation linked with the event
 | 
			
		||||
JOIN "user" u ON u.id = p.user_id            -- and the query user
 | 
			
		||||
JOIN "user" o ON o.id = e.owner_id           -- get the owner info
 | 
			
		||||
WHERE e.id IN (
 | 
			
		||||
    SELECT pt.event_id FROM participation pt WHERE pt.user_id = $1
 | 
			
		||||
    -- consider the events participated by user_id
 | 
			
		||||
)
 | 
			
		||||
GROUP BY
 | 
			
		||||
    e.id, e.name, e.description, e.created_at,
 | 
			
		||||
    o.id, o.first_name, o.last_name;
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 2024/10/19
 | 
			
		||||
 | 
			
		||||
I don't plan to handle deletions at this first stage, but I note down what I
 | 
			
		||||
have thought of.
 | 
			
		||||
 | 
			
		||||
1. Just delete. But keep a replica at the front end of the object that we are
 | 
			
		||||
deleting. And propose an option to restore (so a new record is added to the DB)
 | 
			
		||||
2. Just delete, but wait. The request is sent to a queue with a timeout of
 | 
			
		||||
several seconds, if the user regrets, they can cancel the request. This can be
 | 
			
		||||
done on the front, but also on the back. I think it is better to do in on the
 | 
			
		||||
front-end.
 | 
			
		||||
3. Never deletes. But keep a state in the DB `deleted`. They will just be
 | 
			
		||||
ignored when counting.
 | 
			
		||||
4. Deletes when doing database cleanup. They lines deleted will be processed
 | 
			
		||||
when we cleanup the DB. And they will be definitely deleted at that time.
 | 
			
		||||
 | 
			
		||||
I can create a audit log table to log all the critical
 | 
			
		||||
changes in my `expense` table (update or delete).
 | 
			
		||||
 | 
			
		||||
Finished with the basic SQL commands. Learned a lot from SQL about `JOIN`,
 | 
			
		||||
aggregation and `CTE`. SQL itself has quite amount of things to learn, this
 | 
			
		||||
is on my future learning plan!
 | 
			
		||||
 | 
			
		||||
_I found it quite interesting that simply with SQL, we can simulate the most
 | 
			
		||||
business logic. It is a must-have competence for software design and
 | 
			
		||||
development._
 | 
			
		||||
 | 
			
		||||
### 2024/10/20
 | 
			
		||||
 | 
			
		||||
I was thinking that I should write test for `sqlc` generated code. And then
 | 
			
		||||
I found out `gomock` and see how it is done in the project of
 | 
			
		||||
`techschoo/simplebank`. It's a great tutorial project. It makes me questioning
 | 
			
		||||
my own project's structure. It seems overwhelmed at least at the repo level.
 | 
			
		||||
 | 
			
		||||
I don't actually use the sqlc generated object, instead I do a conversion to
 | 
			
		||||
my `Retrieved` objects. But with some advanced configuration we could make the
 | 
			
		||||
output of sqlc object directly usable. That will save a lot of code.
 | 
			
		||||
 | 
			
		||||
The problem I saw here is the dependency on `sqlc/models`, and the model
 | 
			
		||||
designed there has no business logic. Everything is done in the handlers
 | 
			
		||||
and the handlers query directly the DB.
 | 
			
		||||
 | 
			
		||||
More concretely, `sqlc` generates `RawJSON` for some fields that are embedded
 | 
			
		||||
structs. So I have to do the translation somewhere.
 | 
			
		||||
 | 
			
		||||
So I will just stick to the plan and keep going with the predefined structure.
 | 
			
		||||
 | 
			
		||||
I have to figure out how to use the generated mock files.
 | 
			
		||||
 | 
			
		||||
The goals for the next week is to finish the basic operations for each level
 | 
			
		||||
and run some integration tests with `curl`.
 | 
			
		||||
 | 
			
		||||
### 2024/10/22
 | 
			
		||||
 | 
			
		||||
I am facing come difficulties on testing of the `repo` functions.
 | 
			
		||||
 | 
			
		||||
First, I have to keep the business logic in the service layer. That means I
 | 
			
		||||
have to create the transaction at the service layer. I don't need to depend
 | 
			
		||||
on the implementation detail. So I have created a Transaction interface.
 | 
			
		||||
 | 
			
		||||
I don't care of the type of `tx` because I will pass it to repo layer and I
 | 
			
		||||
suppose that it knows what it is doing. Considering this, my repo `Create`
 | 
			
		||||
function will have to take an any and deduct the type of `tx`. So the layer
 | 
			
		||||
becomes untestable, because I have to pass a *sql.Tx into it and create a
 | 
			
		||||
querier.
 | 
			
		||||
 | 
			
		||||
Since this repo layer is just a wrapping layer between the `sqlc.models` and
 | 
			
		||||
my own models, I can extract the conversion part to functions and test them.
 | 
			
		||||
I'm not testing the whole thing but I test what I can.
 | 
			
		||||
 | 
			
		||||
### 2024/10/24
 | 
			
		||||
 | 
			
		||||
When writing the tests. I am asking myself the differences between `[]T`,
 | 
			
		||||
`[]*T` and `*[]T`.
 | 
			
		||||
 | 
			
		||||
`*[]T` is simple, it is a reference to the original slice. So modifying it
 | 
			
		||||
means modifying the original slice.
 | 
			
		||||
 | 
			
		||||
But between `[]*T` and `[]T`, the only difference that I see (pointed out by
 | 
			
		||||
`ChatGPT`) is how the memory is allocated. With `[]T` it might be better for
 | 
			
		||||
the GC to deal with the memory free. I thing for my project I will stick to
 | 
			
		||||
`[]T`.
 | 
			
		||||
 | 
			
		||||
### 2024/10/25
 | 
			
		||||
 | 
			
		||||
Read this [article](https://konradreiche.com/blog/two-common-go-interface-misuses/)
 | 
			
		||||
today, maybe I am abusing the usage of interfaces?
 | 
			
		||||
 | 
			
		||||
@ -37,20 +37,19 @@ servers:
 | 
			
		||||
  - url: https:/localhost:8000/v1
 | 
			
		||||
tags:
 | 
			
		||||
  - name: user
 | 
			
		||||
  - name: session
 | 
			
		||||
 | 
			
		||||
paths:
 | 
			
		||||
  /user/create:
 | 
			
		||||
  /user/signup:
 | 
			
		||||
    post:
 | 
			
		||||
      tags:
 | 
			
		||||
        - user
 | 
			
		||||
      description: Create a new user
 | 
			
		||||
      description: Sign up as a new user
 | 
			
		||||
      requestBody:
 | 
			
		||||
        description: Create a new user
 | 
			
		||||
        description: Sign up
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/UserCreateRequest'
 | 
			
		||||
              $ref: '#/components/schemas/UserSignUpRequest'
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Successful operation
 | 
			
		||||
@ -59,104 +58,17 @@ paths:
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  code:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: FailedOperation.UserExisted
 | 
			
		||||
                  message:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: "Email already existed."
 | 
			
		||||
                $ref: '#/components/schemas/ErrResponse'
 | 
			
		||||
        '500':
 | 
			
		||||
          description: Server side error
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ErrResponse'
 | 
			
		||||
  /session/create:
 | 
			
		||||
    post:
 | 
			
		||||
      tags:
 | 
			
		||||
        - session
 | 
			
		||||
      description: Create a new session for a user
 | 
			
		||||
      requestBody:
 | 
			
		||||
        description: Create session
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/SessionCreateRequest'
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Successful operation
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  token:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: fakjshdflauhkjhsometokenakjsdhfaksj
 | 
			
		||||
        '400':
 | 
			
		||||
          description: Client side error
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  code:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: AuthFailure
 | 
			
		||||
                  message:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: "wrong email password."
 | 
			
		||||
        '500':
 | 
			
		||||
          description: Server side error
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ErrResponse'
 | 
			
		||||
  /session/delete:
 | 
			
		||||
    post:
 | 
			
		||||
      tags:
 | 
			
		||||
        - session
 | 
			
		||||
      description: Delete an existing session for a user
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Successful operation
 | 
			
		||||
          headers:
 | 
			
		||||
            X-Expires-After:
 | 
			
		||||
              description: date in UTC when token expires
 | 
			
		||||
              schema:
 | 
			
		||||
                type: string
 | 
			
		||||
                format: date-time
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: string
 | 
			
		||||
        '400':
 | 
			
		||||
          description: Client side error
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: object
 | 
			
		||||
                properties:
 | 
			
		||||
                  code:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: AuthFailure
 | 
			
		||||
                  message:
 | 
			
		||||
                    type: string
 | 
			
		||||
                    example: "user not logged in."
 | 
			
		||||
        '500':
 | 
			
		||||
          description: Server side error
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ErrResponse'
 | 
			
		||||
      security:
 | 
			
		||||
        - jwt: []
 | 
			
		||||
 | 
			
		||||
components:
 | 
			
		||||
  schemas:
 | 
			
		||||
    UserCreateRequest:
 | 
			
		||||
    UserSignUpRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      properties:
 | 
			
		||||
        email:
 | 
			
		||||
@ -176,30 +88,12 @@ components:
 | 
			
		||||
        - fist_name
 | 
			
		||||
        - last_name
 | 
			
		||||
        - password
 | 
			
		||||
    SessionCreateRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      properties:
 | 
			
		||||
        email:
 | 
			
		||||
          type: string
 | 
			
		||||
          example: bruce@wayne.com
 | 
			
		||||
        password:
 | 
			
		||||
          type: string
 | 
			
		||||
          example: verystrongpassword
 | 
			
		||||
      required:
 | 
			
		||||
        - email
 | 
			
		||||
        - password
 | 
			
		||||
    ErrResponse:
 | 
			
		||||
      type: object
 | 
			
		||||
      properties:
 | 
			
		||||
        code:
 | 
			
		||||
          type: string
 | 
			
		||||
          example: InternalError
 | 
			
		||||
          example: FailedOperation.UserAlreadyExists
 | 
			
		||||
        message:
 | 
			
		||||
          type: string
 | 
			
		||||
          example: "Server internal error."
 | 
			
		||||
  securitySchemes:
 | 
			
		||||
    jwt:
 | 
			
		||||
      name: Bearer authentication
 | 
			
		||||
      type: http
 | 
			
		||||
      bearerFormat: "JWT"
 | 
			
		||||
      scheme: bearer
 | 
			
		||||
          example: "User already exists."
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,6 @@ dev-mode: true
 | 
			
		||||
web:
 | 
			
		||||
  addr: :8000
 | 
			
		||||
  shutdown-timeout: 10
 | 
			
		||||
  token-secret: nzMC12IJBMiiV2AAktTFpZP4BbGAf09lFPV_sATKcwI
 | 
			
		||||
  token-expiry-time: 24h
 | 
			
		||||
 | 
			
		||||
db:
 | 
			
		||||
  # DB host
 | 
			
		||||
@ -16,14 +14,6 @@ db:
 | 
			
		||||
  # DB name
 | 
			
		||||
  database: howmuch
 | 
			
		||||
 | 
			
		||||
  max-open-conns: 100
 | 
			
		||||
  max-idle-conns: 100
 | 
			
		||||
  max-lifetime: 10s
 | 
			
		||||
 | 
			
		||||
cache:
 | 
			
		||||
  host: 127.0.0.1:6379
 | 
			
		||||
  password: ""
 | 
			
		||||
 | 
			
		||||
log:
 | 
			
		||||
  level: debug
 | 
			
		||||
  disalbe-caller: false
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
services:
 | 
			
		||||
 | 
			
		||||
  postgres:
 | 
			
		||||
    image: postgres:alpine
 | 
			
		||||
    image: postgres
 | 
			
		||||
    restart: always
 | 
			
		||||
    ports:
 | 
			
		||||
      - "5432:5432"
 | 
			
		||||
@ -20,15 +20,3 @@ 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
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 358 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 116 KiB  | 
							
								
								
									
										13
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								go.mod
									
									
									
									
									
								
							@ -6,42 +6,35 @@ require (
 | 
			
		||||
	github.com/fsnotify/fsnotify v1.7.0
 | 
			
		||||
	github.com/gin-contrib/cors v1.7.2
 | 
			
		||||
	github.com/gin-gonic/gin v1.10.0
 | 
			
		||||
	github.com/golang-jwt/jwt/v5 v5.2.1
 | 
			
		||||
	github.com/google/uuid v1.6.0
 | 
			
		||||
	github.com/gosuri/uitable v0.0.4
 | 
			
		||||
	github.com/jackc/pgx/v5 v5.7.1
 | 
			
		||||
	github.com/pkg/errors v0.9.1
 | 
			
		||||
	github.com/redis/go-redis/v9 v9.6.1
 | 
			
		||||
	github.com/spf13/cobra v1.8.1
 | 
			
		||||
	github.com/spf13/pflag v1.0.5
 | 
			
		||||
	github.com/spf13/viper v1.19.0
 | 
			
		||||
	github.com/stretchr/testify v1.9.0
 | 
			
		||||
	go.uber.org/zap v1.27.0
 | 
			
		||||
	golang.org/x/crypto v0.27.0
 | 
			
		||||
	golang.org/x/net v0.26.0
 | 
			
		||||
	golang.org/x/net v0.25.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.22.1 // indirect
 | 
			
		||||
	github.com/go-playground/validator/v10 v10.20.0 // 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
 | 
			
		||||
@ -65,8 +58,8 @@ require (
 | 
			
		||||
	github.com/ugorji/go/codec v1.2.12 // indirect
 | 
			
		||||
	go.uber.org/multierr v1.11.0 // indirect
 | 
			
		||||
	golang.org/x/arch v0.8.0 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.27.0 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
 | 
			
		||||
	golang.org/x/sync v0.8.0 // indirect
 | 
			
		||||
	golang.org/x/sys v0.25.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.18.0 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.34.1 // indirect
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								go.sum
									
									
									
									
									
								
							@ -1,13 +1,7 @@
 | 
			
		||||
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=
 | 
			
		||||
@ -17,8 +11,6 @@ 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=
 | 
			
		||||
@ -39,12 +31,10 @@ 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.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
 | 
			
		||||
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 | 
			
		||||
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 | 
			
		||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
 | 
			
		||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 | 
			
		||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 | 
			
		||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 | 
			
		||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 | 
			
		||||
@ -94,13 +84,9 @@ 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=
 | 
			
		||||
@ -153,8 +139,8 @@ golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
 | 
			
		||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
 | 
			
		||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
 | 
			
		||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
 | 
			
		||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
 | 
			
		||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
 | 
			
		||||
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/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 | 
			
		||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
 | 
			
		||||
@ -28,6 +28,4 @@ type AppController struct {
 | 
			
		||||
	User interface{ User }
 | 
			
		||||
 | 
			
		||||
	Admin interface{ Admin }
 | 
			
		||||
 | 
			
		||||
	Session interface{ Session }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,115 +0,0 @@
 | 
			
		||||
// 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")
 | 
			
		||||
}
 | 
			
		||||
@ -1,190 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package controller
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller/usecasemock"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/middleware/authn"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// {{{ Test Cache
 | 
			
		||||
 | 
			
		||||
type testCache struct {
 | 
			
		||||
	kvMap map[string]interface{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *testCache) Get(ctx context.Context, key string) (string, error) {
 | 
			
		||||
	val, ok := c.kvMap[key]
 | 
			
		||||
	if ok {
 | 
			
		||||
		return val.(string), nil
 | 
			
		||||
	}
 | 
			
		||||
	return "", nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *testCache) Set(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	key string,
 | 
			
		||||
	value interface{},
 | 
			
		||||
	expiration time.Duration,
 | 
			
		||||
) error {
 | 
			
		||||
	c.kvMap[key] = value
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *testCache) Close() error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
 | 
			
		||||
func TestSessionCreate(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		Name  string
 | 
			
		||||
		User  createParams
 | 
			
		||||
		Errno *errno.Errno
 | 
			
		||||
	}{
 | 
			
		||||
		{"registered user", createParams{
 | 
			
		||||
			Email:    "correct@correct.com",
 | 
			
		||||
			Password: "strong password",
 | 
			
		||||
		}, errno.OK},
 | 
			
		||||
		{"unregistered user", createParams{
 | 
			
		||||
			Email:    "unregistered@error.com",
 | 
			
		||||
			Password: "strong password",
 | 
			
		||||
		}, usecase.UserNotExist},
 | 
			
		||||
		{"wrong email", createParams{
 | 
			
		||||
			Email:    "error.com",
 | 
			
		||||
			Password: "strong password",
 | 
			
		||||
		}, UserParamsErr},
 | 
			
		||||
		{"no passwrd", createParams{
 | 
			
		||||
			Email:    "no@error.com",
 | 
			
		||||
			Password: "",
 | 
			
		||||
		}, UserParamsErr},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	token.Init("secret", 1*time.Second)
 | 
			
		||||
 | 
			
		||||
	for _, tst := range tests {
 | 
			
		||||
		t.Run(tst.Name, func(t *testing.T) {
 | 
			
		||||
			testUserUsecase := usecasemock.NewtestUserUsecase()
 | 
			
		||||
			sessionController := NewSessionController(testUserUsecase, nil)
 | 
			
		||||
			r := gin.New()
 | 
			
		||||
			r.POST(
 | 
			
		||||
				"/session/create",
 | 
			
		||||
				func(ctx *gin.Context) { sessionController.Create(ctx) },
 | 
			
		||||
			)
 | 
			
		||||
			user, _ := json.Marshal(tst.User)
 | 
			
		||||
			res := test.PerformRequest(t, r, "POST", "/session/create", bytes.NewReader(user),
 | 
			
		||||
				test.Header{
 | 
			
		||||
					Key:   "content-type",
 | 
			
		||||
					Value: "application/json",
 | 
			
		||||
				})
 | 
			
		||||
 | 
			
		||||
			assert.Equal(t, tst.Errno.HTTP, res.Result().StatusCode, res.Body)
 | 
			
		||||
 | 
			
		||||
			if tst.Errno.HTTP != http.StatusOK {
 | 
			
		||||
				var got errno.Errno
 | 
			
		||||
				err := json.NewDecoder(res.Result().Body).Decode(&got)
 | 
			
		||||
				// XXX: the http status is not in the json. So it must be reset back the the struct
 | 
			
		||||
				got.HTTP = res.Result().StatusCode
 | 
			
		||||
				assert.NoError(t, err)
 | 
			
		||||
				assert.Equal(t, tst.Errno, &got)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var got Token
 | 
			
		||||
			err := json.NewDecoder(res.Result().Body).Decode(&got)
 | 
			
		||||
			assert.NoError(t, err)
 | 
			
		||||
			tkResp, err := token.Parse(got.Token)
 | 
			
		||||
			assert.NoError(t, err)
 | 
			
		||||
			assert.Equal(t, tst.User.Email, tkResp.Identity)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestSessionDelete(t *testing.T) {
 | 
			
		||||
	testUserUsecase := usecasemock.NewtestUserUsecase()
 | 
			
		||||
	kvMap := make(map[string]interface{}, 1)
 | 
			
		||||
	tc := &testCache{kvMap: kvMap}
 | 
			
		||||
	sessionController := NewSessionController(testUserUsecase, tc)
 | 
			
		||||
	r := gin.New()
 | 
			
		||||
	session := r.Group("/session")
 | 
			
		||||
	{
 | 
			
		||||
		session.POST("/create", func(ctx *gin.Context) { sessionController.Create(ctx) })
 | 
			
		||||
		session.Use(authn.Authn(tc))
 | 
			
		||||
		session.POST("/delete", func(ctx *gin.Context) { sessionController.Delete(ctx) })
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	params := createParams{
 | 
			
		||||
		Email:    "correct@correct.com",
 | 
			
		||||
		Password: "strong password",
 | 
			
		||||
	}
 | 
			
		||||
	user, _ := json.Marshal(params)
 | 
			
		||||
	res := test.PerformRequest(t, r, "POST", "/session/create", bytes.NewReader(user),
 | 
			
		||||
		test.Header{
 | 
			
		||||
			Key:   "content-type",
 | 
			
		||||
			Value: "application/json",
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
	var tk Token
 | 
			
		||||
	_ = json.NewDecoder(res.Result().Body).Decode(&tk)
 | 
			
		||||
	tkResp, _ := token.Parse(tk.Token)
 | 
			
		||||
 | 
			
		||||
	// Log out
 | 
			
		||||
	res = test.PerformRequest(t, r, "POST", "/session/delete", nil,
 | 
			
		||||
		test.Header{
 | 
			
		||||
			Key:   "Authorization",
 | 
			
		||||
			Value: fmt.Sprintf("Bearer %s", tkResp.Raw),
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
	var loggedOut string
 | 
			
		||||
	err := json.NewDecoder(res.Result().Body).Decode(&loggedOut)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, "logged out", loggedOut)
 | 
			
		||||
 | 
			
		||||
	// Try to access the handler with the old token
 | 
			
		||||
	res = test.PerformRequest(t, r, "POST", "/session/delete", nil,
 | 
			
		||||
		test.Header{
 | 
			
		||||
			Key:   "Authorization",
 | 
			
		||||
			Value: fmt.Sprintf("Bearer %s", tkResp.Raw),
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
	var unauth errno.Errno
 | 
			
		||||
	err = json.NewDecoder(res.Result().Body).Decode(&unauth)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	unauth.HTTP = res.Result().StatusCode
 | 
			
		||||
	assert.Equal(t, *authn.ErrLoggedOut, unauth)
 | 
			
		||||
}
 | 
			
		||||
@ -1,66 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -35,8 +35,10 @@ import (
 | 
			
		||||
// 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)
 | 
			
		||||
	Signup(core.Context)
 | 
			
		||||
	UpdateInfo(*gin.Context)
 | 
			
		||||
	Login(*gin.Context)
 | 
			
		||||
	Logout(*gin.Context)
 | 
			
		||||
	ChangePassword(*gin.Context)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -56,15 +58,17 @@ func NewUserController(us usecase.User) User {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (uc *UserController) Create(ctx core.Context) {
 | 
			
		||||
	var userRequest model.UserCreateRequest
 | 
			
		||||
func (uc *UserController) Signup(ctx core.Context) {
 | 
			
		||||
	var params model.User
 | 
			
		||||
 | 
			
		||||
	if err := ctx.Bind(&userRequest); err != nil {
 | 
			
		||||
	if err := ctx.Bind(¶ms); err != nil {
 | 
			
		||||
		core.WriteResponse(ctx, UserParamsErr, nil)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err := uc.userUsecase.Create(ctx, &userRequest)
 | 
			
		||||
	// TODO: check params validity (govalidator)
 | 
			
		||||
 | 
			
		||||
	_, err := uc.userUsecase.Create(ctx, ¶ms)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		core.WriteResponse(ctx, err, nil)
 | 
			
		||||
		return
 | 
			
		||||
@ -76,5 +80,11 @@ func (uc *UserController) Create(ctx core.Context) {
 | 
			
		||||
func (uc *UserController) UpdateInfo(ctx *gin.Context) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (uc *UserController) Login(ctx *gin.Context) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (uc *UserController) Logout(ctx *gin.Context) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (uc *UserController) ChangePassword(ctx *gin.Context) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -24,21 +24,17 @@ 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"
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type dbRepository struct {
 | 
			
		||||
	db *sql.DB
 | 
			
		||||
	db *pgx.Conn
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const queryTimeout = 3 * time.Second
 | 
			
		||||
 | 
			
		||||
func NewDBRepository(db *sql.DB) repo.DBRepository {
 | 
			
		||||
func NewDBRepository(db *pgx.Conn) repo.DBRepository {
 | 
			
		||||
	return &dbRepository{
 | 
			
		||||
		db: db,
 | 
			
		||||
	}
 | 
			
		||||
@ -50,31 +46,23 @@ 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)
 | 
			
		||||
	tx, err := dr.db.Begin(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if p := recover(); p != nil {
 | 
			
		||||
			tx.Rollback()
 | 
			
		||||
			tx.Rollback(ctx)
 | 
			
		||||
			log.PanicLog("transaction panicked!")
 | 
			
		||||
		} else if err != nil {
 | 
			
		||||
			tx.Rollback()
 | 
			
		||||
			tx.Rollback(ctx)
 | 
			
		||||
			log.ErrorLog("transaction failed!", "err", err)
 | 
			
		||||
		} else {
 | 
			
		||||
			err = tx.Commit()
 | 
			
		||||
			err = tx.Commit(ctx)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,276 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,122 +0,0 @@
 | 
			
		||||
// 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)
 | 
			
		||||
}
 | 
			
		||||
@ -1,267 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,96 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo/sqlc"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestConvToExpenseRetrieved(t *testing.T) {
 | 
			
		||||
	input := &sqlc.GetExpenseByIDRow{
 | 
			
		||||
		ID:        123,
 | 
			
		||||
		CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
 | 
			
		||||
		UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
 | 
			
		||||
		Amount:    123,
 | 
			
		||||
		Currency:  "EUR",
 | 
			
		||||
		EventID:   123,
 | 
			
		||||
		Name:      sql.NullString{Valid: false},
 | 
			
		||||
		Place:     sql.NullString{Valid: false},
 | 
			
		||||
		Payments: json.RawMessage(
 | 
			
		||||
			`[{"payer_id": 1, "payer_first_name": "toto", "payer_last_name": "titi", "amount": 10, "currency": "EUR"},
 | 
			
		||||
            {"payer_id": 2, "payer_first_name": "tata", "payer_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
 | 
			
		||||
		),
 | 
			
		||||
		Benefits: json.RawMessage(
 | 
			
		||||
			`[{"recipient_id": 1, "recipient_first_name": "toto", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"},
 | 
			
		||||
            {"recipient_id": 2, "recipient_first_name": "tata", "recipient_last_name": "titi", "amount": 10, "currency": "EUR"}]`,
 | 
			
		||||
		),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	want := &model.ExpenseRetrieved{
 | 
			
		||||
		ID:        123,
 | 
			
		||||
		CreatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
 | 
			
		||||
		UpdatedAt: time.Date(2000, time.April, 11, 0, 0, 0, 0, time.UTC),
 | 
			
		||||
		Amount:    model.Money{Amount: 123, Currency: model.Currency("EUR")},
 | 
			
		||||
		EventID:   123,
 | 
			
		||||
		Detail:    model.ExpenseDetail{},
 | 
			
		||||
		Payments: []model.Payment{
 | 
			
		||||
			{
 | 
			
		||||
				PayerID:        1,
 | 
			
		||||
				PayerFirstName: "toto",
 | 
			
		||||
				PayerLastName:  "titi",
 | 
			
		||||
				Amount:         model.Money{Amount: 10, Currency: model.Currency("EUR")},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				PayerID:        2,
 | 
			
		||||
				PayerFirstName: "tata",
 | 
			
		||||
				PayerLastName:  "titi",
 | 
			
		||||
				Amount:         model.Money{Amount: 10, Currency: model.Currency("EUR")},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		Benefits: []model.Benefit{
 | 
			
		||||
			{
 | 
			
		||||
				RecipientID:        1,
 | 
			
		||||
				RecipientFirstName: "toto",
 | 
			
		||||
				RecipientLastName:  "titi",
 | 
			
		||||
				Amount:             model.Money{Amount: 10, Currency: model.Currency("EUR")},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				RecipientID:        2,
 | 
			
		||||
				RecipientFirstName: "tata",
 | 
			
		||||
				RecipientLastName:  "titi",
 | 
			
		||||
				Amount:             model.Money{Amount: 10, Currency: model.Currency("EUR")},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	got, err := convToExpenseRetrieved(input)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, want, got)
 | 
			
		||||
}
 | 
			
		||||
@ -6,14 +6,15 @@ package sqlc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"database/sql"
 | 
			
		||||
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
	"github.com/jackc/pgx/v5/pgconn"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
	Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
 | 
			
		||||
	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
 | 
			
		||||
	QueryRow(context.Context, string, ...interface{}) pgx.Row
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func New(db DBTX) *Queries {
 | 
			
		||||
@ -24,7 +25,7 @@ type Queries struct {
 | 
			
		||||
	db DBTX
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
 | 
			
		||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
 | 
			
		||||
	return &Queries{
 | 
			
		||||
		db: tx,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -1,64 +0,0 @@
 | 
			
		||||
-- 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;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,197 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,64 +0,0 @@
 | 
			
		||||
-- 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;
 | 
			
		||||
@ -1,223 +0,0 @@
 | 
			
		||||
// Code generated by sqlc. DO NOT EDIT.
 | 
			
		||||
// versions:
 | 
			
		||||
//   sqlc v1.27.0
 | 
			
		||||
// source: expense.sql
 | 
			
		||||
 | 
			
		||||
package sqlc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const deleteExpense = `-- name: DeleteExpense :exec
 | 
			
		||||
DELETE FROM "expense" WHERE id = $1
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) DeleteExpense(ctx context.Context, id int32) error {
 | 
			
		||||
	_, err := q.db.ExecContext(ctx, deleteExpense, id)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const deleteTransactionsOfExpenseID = `-- name: DeleteTransactionsOfExpenseID :exec
 | 
			
		||||
DELETE FROM "transaction" WHERE transaction.expense_id = $1
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) DeleteTransactionsOfExpenseID(ctx context.Context, expenseID int32) error {
 | 
			
		||||
	_, err := q.db.ExecContext(ctx, deleteTransactionsOfExpenseID, expenseID)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getExpenseByID = `-- name: GetExpenseByID :one
 | 
			
		||||
WITH payer_transaction as (
 | 
			
		||||
    SELECT pt.expense_id,
 | 
			
		||||
        json_agg(json_build_object(
 | 
			
		||||
            'payer_id', p.id,
 | 
			
		||||
            'payer_first_name', p.first_name,
 | 
			
		||||
            'payer_last_name', p.last_name,
 | 
			
		||||
            'amount', pt.amount,
 | 
			
		||||
            'currency', pt.currency
 | 
			
		||||
        )) AS payments
 | 
			
		||||
    FROM "transaction" pt
 | 
			
		||||
    JOIN "user" p ON p.id = pt.user_id
 | 
			
		||||
    WHERE pt.is_income = FALSE
 | 
			
		||||
    GROUP BY pt.expense_id
 | 
			
		||||
), -- For each expense, aggregate payment info
 | 
			
		||||
recipient_transaction as (
 | 
			
		||||
    SELECT rt.expense_id,
 | 
			
		||||
        json_agg(json_build_object(
 | 
			
		||||
            'recipient_id', p.id,
 | 
			
		||||
            'recipient_first_name', p.first_name,
 | 
			
		||||
            'recipient_last_name', p.last_name,
 | 
			
		||||
            'amount', rt.amount,
 | 
			
		||||
            'currency', rt.currency
 | 
			
		||||
        )) AS benefits
 | 
			
		||||
    FROM "transaction" rt
 | 
			
		||||
    JOIN "user" p ON p.id = rt.user_id
 | 
			
		||||
    WHERE rt.is_income = TRUE
 | 
			
		||||
    GROUP BY rt.expense_id
 | 
			
		||||
) -- For each expense, aggregate benefits info
 | 
			
		||||
SELECT
 | 
			
		||||
    ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
 | 
			
		||||
    ex.name, ex.place,
 | 
			
		||||
    COALESCE(pt.payments, '[]') AS payments,
 | 
			
		||||
    COALESCE(rt.benefits, '[]') AS benefits
 | 
			
		||||
FROM "expense" ex
 | 
			
		||||
LEFT JOIN "payer_transaction" pt ON pt.expense_id = ex.id
 | 
			
		||||
LEFT JOIN "recipient_transaction" rt ON rt.expense_id = ex.id
 | 
			
		||||
WHERE ex.id = $1
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type GetExpenseByIDRow struct {
 | 
			
		||||
	ID        int32
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	EventID   int32
 | 
			
		||||
	Name      sql.NullString
 | 
			
		||||
	Place     sql.NullString
 | 
			
		||||
	Payments  json.RawMessage
 | 
			
		||||
	Benefits  json.RawMessage
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (q *Queries) GetExpenseByID(ctx context.Context, id int32) (GetExpenseByIDRow, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, getExpenseByID, id)
 | 
			
		||||
	var i GetExpenseByIDRow
 | 
			
		||||
	err := row.Scan(
 | 
			
		||||
		&i.ID,
 | 
			
		||||
		&i.CreatedAt,
 | 
			
		||||
		&i.UpdatedAt,
 | 
			
		||||
		&i.Amount,
 | 
			
		||||
		&i.Currency,
 | 
			
		||||
		&i.EventID,
 | 
			
		||||
		&i.Name,
 | 
			
		||||
		&i.Place,
 | 
			
		||||
		&i.Payments,
 | 
			
		||||
		&i.Benefits,
 | 
			
		||||
	)
 | 
			
		||||
	return i, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const insertExpense = `-- name: InsertExpense :one
 | 
			
		||||
INSERT INTO "expense" (
 | 
			
		||||
    created_at, updated_at, amount, currency, event_id, name, place
 | 
			
		||||
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
 | 
			
		||||
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type InsertExpenseParams struct {
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	EventID   int32
 | 
			
		||||
	Name      sql.NullString
 | 
			
		||||
	Place     sql.NullString
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (q *Queries) InsertExpense(ctx context.Context, arg InsertExpenseParams) (Expense, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, insertExpense,
 | 
			
		||||
		arg.CreatedAt,
 | 
			
		||||
		arg.UpdatedAt,
 | 
			
		||||
		arg.Amount,
 | 
			
		||||
		arg.Currency,
 | 
			
		||||
		arg.EventID,
 | 
			
		||||
		arg.Name,
 | 
			
		||||
		arg.Place,
 | 
			
		||||
	)
 | 
			
		||||
	var i Expense
 | 
			
		||||
	err := row.Scan(
 | 
			
		||||
		&i.ID,
 | 
			
		||||
		&i.CreatedAt,
 | 
			
		||||
		&i.UpdatedAt,
 | 
			
		||||
		&i.Amount,
 | 
			
		||||
		&i.Currency,
 | 
			
		||||
		&i.EventID,
 | 
			
		||||
		&i.Name,
 | 
			
		||||
		&i.Place,
 | 
			
		||||
	)
 | 
			
		||||
	return i, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const listExpensesByEventID = `-- name: ListExpensesByEventID :many
 | 
			
		||||
SELECT
 | 
			
		||||
    ex.id, ex.created_at, ex.updated_at, ex.amount, ex.currency, ex.event_id,
 | 
			
		||||
    ex.name, ex.place
 | 
			
		||||
FROM "expense" ex
 | 
			
		||||
JOIN "event" ev ON ev.id = ex.event_id
 | 
			
		||||
WHERE ev.id = $1
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
func (q *Queries) ListExpensesByEventID(ctx context.Context, id int32) ([]Expense, error) {
 | 
			
		||||
	rows, err := q.db.QueryContext(ctx, listExpensesByEventID, id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer rows.Close()
 | 
			
		||||
	var items []Expense
 | 
			
		||||
	for rows.Next() {
 | 
			
		||||
		var i Expense
 | 
			
		||||
		if err := rows.Scan(
 | 
			
		||||
			&i.ID,
 | 
			
		||||
			&i.CreatedAt,
 | 
			
		||||
			&i.UpdatedAt,
 | 
			
		||||
			&i.Amount,
 | 
			
		||||
			&i.Currency,
 | 
			
		||||
			&i.EventID,
 | 
			
		||||
			&i.Name,
 | 
			
		||||
			&i.Place,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		items = append(items, i)
 | 
			
		||||
	}
 | 
			
		||||
	if err := rows.Close(); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	if err := rows.Err(); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return items, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const updateExpenseByID = `-- name: UpdateExpenseByID :one
 | 
			
		||||
UPDATE "expense"
 | 
			
		||||
SET updated_at = $2, amount = $3, currency = $4, name = $5, place = $6
 | 
			
		||||
WHERE id = $1
 | 
			
		||||
RETURNING id, created_at, updated_at, amount, currency, event_id, name, place
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type UpdateExpenseByIDParams struct {
 | 
			
		||||
	ID        int32
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	Name      sql.NullString
 | 
			
		||||
	Place     sql.NullString
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (q *Queries) UpdateExpenseByID(ctx context.Context, arg UpdateExpenseByIDParams) (Expense, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, updateExpenseByID,
 | 
			
		||||
		arg.ID,
 | 
			
		||||
		arg.UpdatedAt,
 | 
			
		||||
		arg.Amount,
 | 
			
		||||
		arg.Currency,
 | 
			
		||||
		arg.Name,
 | 
			
		||||
		arg.Place,
 | 
			
		||||
	)
 | 
			
		||||
	var i Expense
 | 
			
		||||
	err := row.Scan(
 | 
			
		||||
		&i.ID,
 | 
			
		||||
		&i.CreatedAt,
 | 
			
		||||
		&i.UpdatedAt,
 | 
			
		||||
		&i.Amount,
 | 
			
		||||
		&i.Currency,
 | 
			
		||||
		&i.EventID,
 | 
			
		||||
		&i.Name,
 | 
			
		||||
		&i.Place,
 | 
			
		||||
	)
 | 
			
		||||
	return i, err
 | 
			
		||||
}
 | 
			
		||||
@ -5,8 +5,7 @@
 | 
			
		||||
package sqlc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"time"
 | 
			
		||||
	"github.com/jackc/pgx/v5/pgtype"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type Admin struct {
 | 
			
		||||
@ -16,54 +15,12 @@ type Admin struct {
 | 
			
		||||
	AccessLevel int32
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Event struct {
 | 
			
		||||
	ID              int32
 | 
			
		||||
	Name            string
 | 
			
		||||
	Description     sql.NullString
 | 
			
		||||
	DefaultCurrency string
 | 
			
		||||
	OwnerID         int32
 | 
			
		||||
	CreatedAt       time.Time
 | 
			
		||||
	UpdatedAt       time.Time
 | 
			
		||||
	TotalAmount     sql.NullInt32
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Expense struct {
 | 
			
		||||
	ID        int32
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	EventID   int32
 | 
			
		||||
	Name      sql.NullString
 | 
			
		||||
	Place     sql.NullString
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Participation struct {
 | 
			
		||||
	ID              int32
 | 
			
		||||
	UserID          int32
 | 
			
		||||
	EventID         int32
 | 
			
		||||
	InvitedByUserID sql.NullInt32
 | 
			
		||||
	CreatedAt       time.Time
 | 
			
		||||
	UpdatedAt       time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Transaction struct {
 | 
			
		||||
	ID        int32
 | 
			
		||||
	ExpenseID int32
 | 
			
		||||
	UserID    int32
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	IsIncome  bool
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type User struct {
 | 
			
		||||
	ID        int32
 | 
			
		||||
	Email     string
 | 
			
		||||
	FirstName string
 | 
			
		||||
	LastName  string
 | 
			
		||||
	Password  string
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	CreatedAt pgtype.Timestamp
 | 
			
		||||
	UpdatedAt pgtype.Timestamp
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
-- 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;
 | 
			
		||||
@ -1,72 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,30 +0,0 @@
 | 
			
		||||
// 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)
 | 
			
		||||
@ -1,5 +0,0 @@
 | 
			
		||||
-- 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 *;
 | 
			
		||||
@ -1,41 +0,0 @@
 | 
			
		||||
// Code generated by sqlc. DO NOT EDIT.
 | 
			
		||||
// versions:
 | 
			
		||||
//   sqlc v1.27.0
 | 
			
		||||
// source: transaction.sql
 | 
			
		||||
 | 
			
		||||
package sqlc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const insertTransaction = `-- name: InsertTransaction :exec
 | 
			
		||||
INSERT INTO "transaction" (
 | 
			
		||||
    created_at, updated_at, amount, currency, expense_id, user_id, is_income
 | 
			
		||||
) VALUES ( $1, $2, $3, $4, $5, $6, $7 )
 | 
			
		||||
RETURNING id, expense_id, user_id, amount, currency, is_income, created_at, updated_at
 | 
			
		||||
`
 | 
			
		||||
 | 
			
		||||
type InsertTransactionParams struct {
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	Amount    int32
 | 
			
		||||
	Currency  string
 | 
			
		||||
	ExpenseID int32
 | 
			
		||||
	UserID    int32
 | 
			
		||||
	IsIncome  bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (q *Queries) InsertTransaction(ctx context.Context, arg InsertTransactionParams) error {
 | 
			
		||||
	_, err := q.db.ExecContext(ctx, insertTransaction,
 | 
			
		||||
		arg.CreatedAt,
 | 
			
		||||
		arg.UpdatedAt,
 | 
			
		||||
		arg.Amount,
 | 
			
		||||
		arg.Currency,
 | 
			
		||||
		arg.ExpenseID,
 | 
			
		||||
		arg.UserID,
 | 
			
		||||
		arg.IsIncome,
 | 
			
		||||
	)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
@ -1,15 +1,27 @@
 | 
			
		||||
-- MIT License
 | 
			
		||||
--
 | 
			
		||||
-- Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
--
 | 
			
		||||
-- Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
-- of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
-- in the Software without restriction, including without limitation the rights
 | 
			
		||||
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
-- copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
-- furnished to do so, subject to the following conditions:
 | 
			
		||||
--
 | 
			
		||||
-- The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
-- copies or substantial portions of the Software.
 | 
			
		||||
--
 | 
			
		||||
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
-- SOFTWARE.
 | 
			
		||||
 | 
			
		||||
-- name: InsertUser :one
 | 
			
		||||
INSERT INTO "user" (
 | 
			
		||||
    email, first_name, last_name, password, created_at, updated_at
 | 
			
		||||
) VALUES ( $1, $2, $3, $4, $5, $6 )
 | 
			
		||||
RETURNING *;
 | 
			
		||||
 | 
			
		||||
-- name: GetUserByEmail :one
 | 
			
		||||
SELECT id, email, first_name, last_name, password, created_at, updated_at
 | 
			
		||||
    FROM "user"
 | 
			
		||||
    WHERE email = $1;
 | 
			
		||||
 | 
			
		||||
-- name: GetUserByID :one
 | 
			
		||||
SELECT id, email, first_name, last_name, password, created_at, updated_at
 | 
			
		||||
    FROM "user"
 | 
			
		||||
    WHERE id = $1;
 | 
			
		||||
 | 
			
		||||
@ -7,52 +7,12 @@ package sqlc
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/jackc/pgx/v5/pgtype"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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 )
 | 
			
		||||
@ -64,12 +24,33 @@ type InsertUserParams struct {
 | 
			
		||||
	FirstName string
 | 
			
		||||
	LastName  string
 | 
			
		||||
	Password  string
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
	CreatedAt pgtype.Timestamp
 | 
			
		||||
	UpdatedAt pgtype.Timestamp
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
 | 
			
		||||
	row := q.db.QueryRowContext(ctx, insertUser,
 | 
			
		||||
	row := q.db.QueryRow(ctx, insertUser,
 | 
			
		||||
		arg.Email,
 | 
			
		||||
		arg.FirstName,
 | 
			
		||||
		arg.LastName,
 | 
			
		||||
 | 
			
		||||
@ -24,110 +24,65 @@ 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"
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
	"github.com/jackc/pgx/v5/pgtype"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type userRepository struct {
 | 
			
		||||
	queries *sqlc.Queries
 | 
			
		||||
	db *pgx.Conn
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewUserRepository(db *sql.DB) repo.UserRepository {
 | 
			
		||||
const insertTimeout = 1 * time.Second
 | 
			
		||||
 | 
			
		||||
func NewUserRepository(db *pgx.Conn) repo.UserRepository {
 | 
			
		||||
	return &userRepository{
 | 
			
		||||
		queries: sqlc.New(db),
 | 
			
		||||
		db: db,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create
 | 
			
		||||
func (u *userRepository) Create(
 | 
			
		||||
func (ur *userRepository) Create(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	userEntity *model.UserEntity,
 | 
			
		||||
	tx any,
 | 
			
		||||
) (*model.UserEntity, error) {
 | 
			
		||||
	timeoutCtx, cancel := context.WithTimeout(ctx, queryTimeout)
 | 
			
		||||
	transaction interface{},
 | 
			
		||||
	u *model.User,
 | 
			
		||||
) (*model.User, error) {
 | 
			
		||||
	timeoutCtx, cancel := context.WithTimeout(ctx, insertTimeout)
 | 
			
		||||
	defer cancel()
 | 
			
		||||
 | 
			
		||||
	queries := getQueries(u.queries, tx)
 | 
			
		||||
	args := sqlc.InsertUserParams{
 | 
			
		||||
		Email:     u.Email,
 | 
			
		||||
		FirstName: u.FirstName,
 | 
			
		||||
		LastName:  u.LastName,
 | 
			
		||||
		Password:  u.Password,
 | 
			
		||||
		CreatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
 | 
			
		||||
		UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	userDB, err := queries.InsertUser(timeoutCtx, sqlc.InsertUserParams{
 | 
			
		||||
		Email:     userEntity.Email,
 | 
			
		||||
		FirstName: userEntity.FirstName,
 | 
			
		||||
		LastName:  userEntity.LastName,
 | 
			
		||||
		Password:  userEntity.Password,
 | 
			
		||||
		CreatedAt: time.Now(),
 | 
			
		||||
		UpdatedAt: time.Now(),
 | 
			
		||||
	})
 | 
			
		||||
	tx, ok := transaction.(pgx.Tx)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		return nil, errors.New("transaction is not a pgx.Tx")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	queries := sqlc.New(tx)
 | 
			
		||||
 | 
			
		||||
	userDB, err := queries.InsertUser(timeoutCtx, args)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &model.UserEntity{
 | 
			
		||||
	return &model.User{
 | 
			
		||||
		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,
 | 
			
		||||
		CreatedAt: userDB.CreatedAt.Time,
 | 
			
		||||
		UpdatedAt: userDB.CreatedAt.Time,
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -35,10 +35,9 @@ import (
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/infra/router"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/registry"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/pkg/version/verflag"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/redis/go-redis/v9"
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
	"github.com/spf13/viper"
 | 
			
		||||
	"golang.org/x/net/context"
 | 
			
		||||
@ -116,35 +115,22 @@ func run() error {
 | 
			
		||||
		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"),
 | 
			
		||||
	dbConf, err := pgx.ParseConfig(dbConfString)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.FatalLog("DB connection config failure", "err", err, "cfg string", dbConfString)
 | 
			
		||||
	}
 | 
			
		||||
	dbConn := datastore.NewDB(dbConfString, dbExtraConf)
 | 
			
		||||
	if dbConn == nil {
 | 
			
		||||
		log.FatalLog("DB connection failure")
 | 
			
		||||
	dbConn, err := datastore.NewDB(dbConf)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.FatalLog("DB connection failure", "err", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer dbConn.Close()
 | 
			
		||||
 | 
			
		||||
	// Init Cache
 | 
			
		||||
	cache := datastore.NewCache(&redis.Options{
 | 
			
		||||
		Addr:     viper.GetString("cache.host"),
 | 
			
		||||
		Password: viper.GetString("cache.password"),
 | 
			
		||||
		DB:       0,
 | 
			
		||||
	})
 | 
			
		||||
	defer cache.Close()
 | 
			
		||||
 | 
			
		||||
	// Init token
 | 
			
		||||
	token.Init(viper.GetString("web.token-secret"), viper.GetDuration("web.token-expiry-time"))
 | 
			
		||||
	defer dbConn.Close(context.Background())
 | 
			
		||||
 | 
			
		||||
	// Register the core service
 | 
			
		||||
	r := registry.NewRegistry(dbConn, cache)
 | 
			
		||||
	r := registry.NewRegistry(dbConn)
 | 
			
		||||
 | 
			
		||||
	engine := gin.Default()
 | 
			
		||||
 | 
			
		||||
	engine = router.Routes(engine, r.NewAppController(), cache)
 | 
			
		||||
	engine = router.Routes(engine, r.NewAppController())
 | 
			
		||||
 | 
			
		||||
	server := http.Server{
 | 
			
		||||
		Addr:    viper.GetString("web.addr"),
 | 
			
		||||
 | 
			
		||||
@ -1,66 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package datastore
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
 | 
			
		||||
	"github.com/redis/go-redis/v9"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type RedisCache struct {
 | 
			
		||||
	redis *redis.Client
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewCache(opt interface{}) core.Cache {
 | 
			
		||||
	redisOpt := opt.(*redis.Options)
 | 
			
		||||
	return &RedisCache{redis.NewClient(redisOpt)}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *RedisCache) Get(ctx context.Context, key string) (string, error) {
 | 
			
		||||
	val, err := c.redis.Get(ctx, key).Result()
 | 
			
		||||
	if err == redis.Nil {
 | 
			
		||||
		log.DebugLog("redis key not found", "key", key)
 | 
			
		||||
		return "", nil
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		log.DebugLog("redis cache get error", "err", err)
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	return val, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *RedisCache) Set(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	key string,
 | 
			
		||||
	value interface{},
 | 
			
		||||
	expiration time.Duration,
 | 
			
		||||
) error {
 | 
			
		||||
	return c.redis.Set(ctx, key, value, expiration).Err()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *RedisCache) Close() error {
 | 
			
		||||
	return c.redis.Close()
 | 
			
		||||
}
 | 
			
		||||
@ -23,65 +23,22 @@
 | 
			
		||||
package datastore
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"time"
 | 
			
		||||
	"context"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/log"
 | 
			
		||||
	_ "github.com/jackc/pgx/v5"
 | 
			
		||||
	_ "github.com/jackc/pgx/v5/stdlib"
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
func NewDB(connConfig *pgx.ConnConfig) (*pgx.Conn, error) {
 | 
			
		||||
	conn, err := pgx.ConnectConfig(context.Background(), connConfig)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := db.Ping(); err != nil {
 | 
			
		||||
	// Ping test the conn
 | 
			
		||||
	if err = conn.Ping(context.Background()); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return db, nil
 | 
			
		||||
 | 
			
		||||
	return conn, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -23,25 +23,15 @@
 | 
			
		||||
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")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
func Routes(engine *gin.Engine, c controller.AppController) *gin.Engine {
 | 
			
		||||
	// Middlewares
 | 
			
		||||
	// Cors
 | 
			
		||||
	corsCfg := cors.DefaultConfig()
 | 
			
		||||
@ -62,22 +52,8 @@ func Routes(engine *gin.Engine, c controller.AppController, opt ...interface{})
 | 
			
		||||
	{
 | 
			
		||||
		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") },
 | 
			
		||||
			)
 | 
			
		||||
			userV1.POST("/signup", func(ctx *gin.Context) { c.User.Signup(ctx) })
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		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
 | 
			
		||||
 | 
			
		||||
@ -1,136 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
@ -1,142 +0,0 @@
 | 
			
		||||
// 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"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
@ -1,66 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,39 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,48 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package model
 | 
			
		||||
 | 
			
		||||
import "time"
 | 
			
		||||
 | 
			
		||||
// {{{ Entity
 | 
			
		||||
 | 
			
		||||
type TransactionEntity Transaction
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// {{{ Domain object
 | 
			
		||||
 | 
			
		||||
type Transaction struct {
 | 
			
		||||
	ID int
 | 
			
		||||
 | 
			
		||||
	ExpenseID int
 | 
			
		||||
	UserID    int
 | 
			
		||||
	Amount    int
 | 
			
		||||
	Currency  string
 | 
			
		||||
	IsIncome  bool // To note that the direction of the money (payment or income)
 | 
			
		||||
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// Transaction is the association between Expenses and Users
 | 
			
		||||
@ -24,77 +24,13 @@ 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"`
 | 
			
		||||
// User model
 | 
			
		||||
type User struct {
 | 
			
		||||
	ID        int       `json:"id"`
 | 
			
		||||
	Email     string    `json:"email"`
 | 
			
		||||
	FirstName string    `json:"first_name"`
 | 
			
		||||
	LastName  string    `json:"last_name"`
 | 
			
		||||
	Password  string    `json:"password"`
 | 
			
		||||
	CreatedAt time.Time `json:"created_at"`
 | 
			
		||||
	UpdatedAt time.Time `json:"updated_at"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserExistRequest struct {
 | 
			
		||||
	Email    string `json:"email"    binding:"required,email"`
 | 
			
		||||
	Password string `json:"password" binding:"required"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// {{{ Response View Object (from service to controller)
 | 
			
		||||
 | 
			
		||||
type UserBaseResponse UserBaseRetrieved
 | 
			
		||||
 | 
			
		||||
type UserInfoResponse struct {
 | 
			
		||||
	// UserBaseResponse
 | 
			
		||||
	ID        int    `json:"id"`
 | 
			
		||||
	FirstName string `json:"first_name"`
 | 
			
		||||
	LastName  string `json:"last_name"`
 | 
			
		||||
 | 
			
		||||
	Email     string `json:"email"`
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// {{{ Entity (DB In)
 | 
			
		||||
 | 
			
		||||
type UserEntity struct {
 | 
			
		||||
	ID int
 | 
			
		||||
 | 
			
		||||
	Email     string
 | 
			
		||||
	FirstName string
 | 
			
		||||
	LastName  string
 | 
			
		||||
	Password  string
 | 
			
		||||
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// {{{ Retrieved (DB out)
 | 
			
		||||
 | 
			
		||||
type UserBaseRetrieved struct {
 | 
			
		||||
	ID        int    `json:"id"`
 | 
			
		||||
	FirstName string `json:"first_name"`
 | 
			
		||||
	LastName  string `json:"last_name"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
// {{{ DO Domain Object (Contains the domain service)
 | 
			
		||||
 | 
			
		||||
// TODO: For now I don't know what to do with this model
 | 
			
		||||
type UserDO struct {
 | 
			
		||||
	ID int
 | 
			
		||||
 | 
			
		||||
	Email     string
 | 
			
		||||
	FirstName string
 | 
			
		||||
	LastName  string
 | 
			
		||||
	Password  string
 | 
			
		||||
 | 
			
		||||
	// Lazy aggregate with the Participation join
 | 
			
		||||
	EventIDs []int
 | 
			
		||||
 | 
			
		||||
	CreatedAt time.Time
 | 
			
		||||
	UpdatedAt time.Time
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// }}}
 | 
			
		||||
 | 
			
		||||
@ -23,10 +23,8 @@
 | 
			
		||||
package registry
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"database/sql"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/core"
 | 
			
		||||
	"github.com/jackc/pgx/v5"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// registry is an implementation of Registry interface.
 | 
			
		||||
@ -34,8 +32,7 @@ import (
 | 
			
		||||
// It might holds other drivers when the projects grows. For example
 | 
			
		||||
// the object needed to connect to Redis or Kafka.
 | 
			
		||||
type registry struct {
 | 
			
		||||
	db    *sql.DB
 | 
			
		||||
	cache core.Cache
 | 
			
		||||
	db *pgx.Conn
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Registry returns a new app controller that will be used by main()/run()
 | 
			
		||||
@ -46,16 +43,15 @@ type Registry interface {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewRegistry returns a new Registry's implementation.
 | 
			
		||||
func NewRegistry(db *sql.DB, cache core.Cache) Registry {
 | 
			
		||||
	return ®istry{db: db, cache: cache}
 | 
			
		||||
func NewRegistry(db *pgx.Conn) Registry {
 | 
			
		||||
	return ®istry{db: db}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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(),
 | 
			
		||||
		User:  r.NewUserController(),
 | 
			
		||||
		Admin: r.NewAdminController(),
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,35 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package registry
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/controller"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/adapter/repo"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// NewSessionController returns a session controller's implementation
 | 
			
		||||
func (r *registry) NewSessionController() controller.Session {
 | 
			
		||||
	u := usecase.NewUserUsecase(repo.NewUserRepository(r.db), repo.NewDBRepository(r.db))
 | 
			
		||||
	return controller.NewSessionController(u, r.cache)
 | 
			
		||||
}
 | 
			
		||||
@ -22,9 +22,7 @@
 | 
			
		||||
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
)
 | 
			
		||||
import "context"
 | 
			
		||||
 | 
			
		||||
type DBRepository interface {
 | 
			
		||||
	Transaction(
 | 
			
		||||
 | 
			
		||||
@ -1,74 +0,0 @@
 | 
			
		||||
// 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)
 | 
			
		||||
}
 | 
			
		||||
@ -20,11 +20,9 @@
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package repomock
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
)
 | 
			
		||||
import "context"
 | 
			
		||||
 | 
			
		||||
type TestDBRepository struct{}
 | 
			
		||||
 | 
			
		||||
@ -20,15 +20,23 @@
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package core
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
type TestUserRepository struct{}
 | 
			
		||||
 | 
			
		||||
func (tur *TestUserRepository) Create(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	transaction interface{},
 | 
			
		||||
	u *model.User,
 | 
			
		||||
) (*model.User, error) {
 | 
			
		||||
	user := *u
 | 
			
		||||
 | 
			
		||||
	user.ID = 123
 | 
			
		||||
	return &user, nil
 | 
			
		||||
}
 | 
			
		||||
@ -29,11 +29,5 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
	Create(ctx context.Context, transaction interface{}, u *model.User) (*model.User, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,190 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,94 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -24,34 +24,11 @@ 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 {
 | 
			
		||||
@ -60,9 +37,7 @@ type userUsecase struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
	Create(ctx context.Context, u *model.User) (*model.User, error)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
 | 
			
		||||
@ -72,31 +47,12 @@ func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
func (uuc *userUsecase) Create(ctx context.Context, u *model.User) (*model.User, error) {
 | 
			
		||||
	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)
 | 
			
		||||
			u, err := uuc.userRepo.Create(txCtx, tx, u)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				match, _ := regexp.MatchString("SQLSTATE 23505", err.Error())
 | 
			
		||||
				if match {
 | 
			
		||||
					return nil, UserExisted
 | 
			
		||||
				}
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -104,70 +60,19 @@ func (uuc *userUsecase) Create(
 | 
			
		||||
			log.InfoLog(
 | 
			
		||||
				"created new user",
 | 
			
		||||
				"email",
 | 
			
		||||
				created.Email,
 | 
			
		||||
				u.Email,
 | 
			
		||||
				"name",
 | 
			
		||||
				fmt.Sprintf("%s %s", created.FirstName, created.LastName),
 | 
			
		||||
				fmt.Sprintf("%s %s", u.FirstName, u.LastName),
 | 
			
		||||
			)
 | 
			
		||||
 | 
			
		||||
			return created, err
 | 
			
		||||
			return u, 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,
 | 
			
		||||
	}
 | 
			
		||||
	user := data.(*model.User)
 | 
			
		||||
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -27,73 +27,23 @@ import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase/repomock"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo"
 | 
			
		||||
	"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},
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
	userUsecase := NewUserUsecase(&repo.TestUserRepository{}, &repo.TestDBRepository{})
 | 
			
		||||
	input := &model.User{
 | 
			
		||||
		Email:     "a@b.c",
 | 
			
		||||
		FirstName: "James",
 | 
			
		||||
		LastName:  "Bond",
 | 
			
		||||
		Password:  "verystrong",
 | 
			
		||||
	}
 | 
			
		||||
	want := input
 | 
			
		||||
	want.ID = 123
 | 
			
		||||
 | 
			
		||||
	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)
 | 
			
		||||
	}
 | 
			
		||||
	got, err := userUsecase.Create(ctx, input)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, want, got)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,6 @@ type Context interface {
 | 
			
		||||
 | 
			
		||||
	// Request
 | 
			
		||||
	Bind(obj any) error
 | 
			
		||||
	GetHeader(key string) string
 | 
			
		||||
 | 
			
		||||
	// Response
 | 
			
		||||
	JSON(code int, obj any)
 | 
			
		||||
 | 
			
		||||
@ -26,8 +26,6 @@ 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"
 | 
			
		||||
)
 | 
			
		||||
@ -102,30 +100,6 @@ 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...)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,77 +0,0 @@
 | 
			
		||||
// 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()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -1,117 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package authn
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/errno"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/shared"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/test"
 | 
			
		||||
	"git.vinchent.xyz/vinchent/howmuch/internal/pkg/token"
 | 
			
		||||
	"github.com/gin-gonic/gin"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var loggedOutID string
 | 
			
		||||
 | 
			
		||||
type testCache struct{}
 | 
			
		||||
 | 
			
		||||
func (tc *testCache) Get(ctx context.Context, key string) (string, error) {
 | 
			
		||||
	loggedOutKey := fmt.Sprintf("jwt:%s", loggedOutID)
 | 
			
		||||
	if key == loggedOutKey {
 | 
			
		||||
		return "found", nil
 | 
			
		||||
	}
 | 
			
		||||
	return "", nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *testCache) Set(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	key string,
 | 
			
		||||
	value interface{},
 | 
			
		||||
	expiration time.Duration,
 | 
			
		||||
) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (c *testCache) Close() error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestAuthn(t *testing.T) {
 | 
			
		||||
	token.Init("secret", 1*time.Second)
 | 
			
		||||
	tk, _ := token.Sign("user")
 | 
			
		||||
 | 
			
		||||
	tkParsed, _ := token.Parse(tk)
 | 
			
		||||
	loggedOutID = tkParsed.Identity
 | 
			
		||||
 | 
			
		||||
	cache := &testCache{}
 | 
			
		||||
 | 
			
		||||
	t.Run("token found in cache", func(t *testing.T) {
 | 
			
		||||
		r := gin.New()
 | 
			
		||||
		r.Use(Authn(cache))
 | 
			
		||||
 | 
			
		||||
		r.GET("/example", func(c *gin.Context) {
 | 
			
		||||
			c.Status(http.StatusOK)
 | 
			
		||||
		})
 | 
			
		||||
		res := test.PerformRequest(
 | 
			
		||||
			t,
 | 
			
		||||
			r,
 | 
			
		||||
			"GET",
 | 
			
		||||
			"/example",
 | 
			
		||||
			nil,
 | 
			
		||||
			test.Header{Key: shared.XUserName, Value: "user"},
 | 
			
		||||
			test.Header{Key: "Authorization", Value: fmt.Sprintf("Bearer %s", tk)},
 | 
			
		||||
		)
 | 
			
		||||
		assert.Equal(t, http.StatusUnauthorized, res.Result().StatusCode, res.Body)
 | 
			
		||||
 | 
			
		||||
		var err errno.Errno
 | 
			
		||||
		json.NewDecoder(res.Result().Body).Decode(&err)
 | 
			
		||||
		assert.Equal(t, "AuthFailure.LoggedOut", err.Code)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("token not found in cache", func(t *testing.T) {
 | 
			
		||||
		newTk, _ := token.Sign("user2")
 | 
			
		||||
		r := gin.New()
 | 
			
		||||
		r.Use(Authn(cache))
 | 
			
		||||
 | 
			
		||||
		r.GET("/example", func(c *gin.Context) {
 | 
			
		||||
			c.Status(http.StatusOK)
 | 
			
		||||
		})
 | 
			
		||||
		res := test.PerformRequest(
 | 
			
		||||
			t,
 | 
			
		||||
			r,
 | 
			
		||||
			"GET",
 | 
			
		||||
			"/example",
 | 
			
		||||
			nil,
 | 
			
		||||
			test.Header{Key: shared.XUserName, Value: "user2"},
 | 
			
		||||
			test.Header{Key: "Authorization", Value: fmt.Sprintf("Bearer %s", newTk)},
 | 
			
		||||
		)
 | 
			
		||||
		assert.Equal(t, http.StatusOK, res.Result().StatusCode, res.Body)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@ -23,23 +23,24 @@
 | 
			
		||||
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(shared.XRequestID); rid != "" {
 | 
			
		||||
			ctx.Request.Header.Add(shared.XRequestID, rid)
 | 
			
		||||
		if rid = ctx.GetHeader(requestID); rid != "" {
 | 
			
		||||
			ctx.Request.Header.Add(requestID, rid)
 | 
			
		||||
			ctx.Next()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rid = uuid.NewString()
 | 
			
		||||
		ctx.Request.Header.Add(shared.XRequestID, rid)
 | 
			
		||||
		ctx.Header(shared.XRequestID, rid)
 | 
			
		||||
		ctx.Request.Header.Add(requestID, rid)
 | 
			
		||||
		ctx.Header(requestID, rid)
 | 
			
		||||
		ctx.Next()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -24,15 +24,33 @@ 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())
 | 
			
		||||
@ -40,28 +58,21 @@ func TestRequestID(t *testing.T) {
 | 
			
		||||
	wanted := "123"
 | 
			
		||||
 | 
			
		||||
	r.GET("/example", func(c *gin.Context) {
 | 
			
		||||
		got = c.GetHeader(shared.XRequestID)
 | 
			
		||||
		got = c.GetHeader(requestID)
 | 
			
		||||
		c.Status(http.StatusOK)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	r.POST("/example", func(c *gin.Context) {
 | 
			
		||||
		got = c.GetHeader(shared.XRequestID)
 | 
			
		||||
		got = c.GetHeader(requestID)
 | 
			
		||||
		c.String(http.StatusAccepted, "ok")
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Test with Request ID
 | 
			
		||||
	_ = test.PerformRequest(
 | 
			
		||||
		t,
 | 
			
		||||
		r,
 | 
			
		||||
		"GET",
 | 
			
		||||
		"/example?a=100",
 | 
			
		||||
		nil,
 | 
			
		||||
		test.Header{Key: shared.XRequestID, Value: wanted},
 | 
			
		||||
	)
 | 
			
		||||
	_ = performRequest(r, "GET", "/example?a=100", header{requestID, wanted})
 | 
			
		||||
	assert.Equal(t, "123", got)
 | 
			
		||||
 | 
			
		||||
	res := test.PerformRequest(t, r, "GET", "/example?a=100", nil)
 | 
			
		||||
	res := performRequest(r, "GET", "/example?a=100")
 | 
			
		||||
	assert.NotEqual(t, "", got)
 | 
			
		||||
	assert.NoError(t, uuid.Validate(got))
 | 
			
		||||
	assert.Equal(t, res.Header()[shared.XRequestID][0], got)
 | 
			
		||||
	assert.Equal(t, res.Header()[requestID][0], got)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,28 +0,0 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
// of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
// in the Software without restriction, including without limitation the rights
 | 
			
		||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
// copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
// furnished to do so, subject to the following conditions:
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
// copies or substantial portions of the Software.
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
package shared
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	XRequestID = "X-Request-Id"
 | 
			
		||||
	XUserName  = "X-Username"
 | 
			
		||||
)
 | 
			
		||||
@ -1,52 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,117 +0,0 @@
 | 
			
		||||
// 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)
 | 
			
		||||
}
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
DROP TABLE "event"
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
);
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
DROP TABLE participation;
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
ALTER TABLE "event"
 | 
			
		||||
ADD "total_amount" integer NULL;
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
ALTER TABLE "participation"
 | 
			
		||||
ADD CONSTRAINT unique_user_event UNIQUE ("user_id", "event_id");
 | 
			
		||||
@ -1,14 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
DROP TABLE "expense";
 | 
			
		||||
@ -1,14 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
DROP TABLE transaction;
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
ALTER TABLE "transaction"
 | 
			
		||||
ADD FOREIGN KEY ("expense_id") REFERENCES "expense" ("id") ON DELETE CASCADE ON UPDATE CASCADE
 | 
			
		||||
							
								
								
									
										2
									
								
								sqlc.yml
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								sqlc.yml
									
									
									
									
									
								
							@ -28,4 +28,4 @@ sql:
 | 
			
		||||
    gen:
 | 
			
		||||
      go:
 | 
			
		||||
        out: "internal/howmuch/adapter/repo/sqlc"
 | 
			
		||||
        emit_interface: true
 | 
			
		||||
        sql_package: "pgx/v5"
 | 
			
		||||
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
/* 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
									
									
								
							
							
						
						
									
										30
									
								
								web/.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1,30 +0,0 @@
 | 
			
		||||
# 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
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://json.schemastore.org/prettierrc",
 | 
			
		||||
  "semi": false,
 | 
			
		||||
  "tabWidth": 2,
 | 
			
		||||
  "singleQuote": true,
 | 
			
		||||
  "printWidth": 100,
 | 
			
		||||
  "trailingComma": "none"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								web/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								web/.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							@ -1,7 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "recommendations": [
 | 
			
		||||
    "Vue.volar",
 | 
			
		||||
    "dbaeumer.vscode-eslint",
 | 
			
		||||
    "esbenp.prettier-vscode"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								web/Makefile
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								web/Makefile
									
									
									
									
									
								
							@ -1,22 +0,0 @@
 | 
			
		||||
.PHONY: all
 | 
			
		||||
all: add-copyright format lint build test
 | 
			
		||||
 | 
			
		||||
.PHONY: build
 | 
			
		||||
build:
 | 
			
		||||
	@npm install && npm run build
 | 
			
		||||
 | 
			
		||||
.PHONY: format
 | 
			
		||||
format: # format code.
 | 
			
		||||
	@npm run format
 | 
			
		||||
 | 
			
		||||
.PHONY: lint
 | 
			
		||||
lint:
 | 
			
		||||
	@npm run lint
 | 
			
		||||
 | 
			
		||||
.PHONY: test
 | 
			
		||||
test:
 | 
			
		||||
	@npm run test:unit run
 | 
			
		||||
 | 
			
		||||
.PHONY: add-copyright
 | 
			
		||||
add-copyright: # add license to file headers.
 | 
			
		||||
	@addlicense -v -f ../LICENSE ./src --skip-files=database.yml
 | 
			
		||||
@ -1,45 +0,0 @@
 | 
			
		||||
# web
 | 
			
		||||
 | 
			
		||||
This template should help get you started developing with Vue 3 in Vite.
 | 
			
		||||
 | 
			
		||||
## Recommended IDE Setup
 | 
			
		||||
 | 
			
		||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
 | 
			
		||||
 | 
			
		||||
## Type Support for `.vue` Imports in TS
 | 
			
		||||
 | 
			
		||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
 | 
			
		||||
 | 
			
		||||
## Customize configuration
 | 
			
		||||
 | 
			
		||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
 | 
			
		||||
 | 
			
		||||
## Project Setup
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm install
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Compile and Hot-Reload for Development
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Type-Check, Compile and Minify for Production
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run test:unit
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Lint with [ESLint](https://eslint.org/)
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
npm run lint
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										1
									
								
								web/env.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								web/env.d.ts
									
									
									
									
										vendored
									
									
								
							@ -1 +0,0 @@
 | 
			
		||||
/// <reference types="vite/client" />
 | 
			
		||||
@ -1,13 +0,0 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8">
 | 
			
		||||
    <link rel="icon" href="/favicon.ico">
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
    <title>Vite App</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body>
 | 
			
		||||
    <div id="app"></div>
 | 
			
		||||
    <script type="module" src="/src/main.ts"></script>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										5988
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5988
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,42 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "web",
 | 
			
		||||
  "version": "0.0.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "vite",
 | 
			
		||||
    "build": "run-p type-check \"build-only {@}\" --",
 | 
			
		||||
    "preview": "vite preview",
 | 
			
		||||
    "test:unit": "vitest",
 | 
			
		||||
    "build-only": "vite build",
 | 
			
		||||
    "type-check": "vue-tsc --build --force",
 | 
			
		||||
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
 | 
			
		||||
    "format": "prettier --write src/"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "axios": "^1.7.7",
 | 
			
		||||
    "vue": "^3.4.29",
 | 
			
		||||
    "vue-router": "^4.3.3"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@rushstack/eslint-patch": "^1.8.0",
 | 
			
		||||
    "@tsconfig/node20": "^20.1.4",
 | 
			
		||||
    "@types/jsdom": "^21.1.7",
 | 
			
		||||
    "@types/node": "^20.14.5",
 | 
			
		||||
    "@vitejs/plugin-vue": "^5.0.5",
 | 
			
		||||
    "@vue/eslint-config-prettier": "^9.0.0",
 | 
			
		||||
    "@vue/eslint-config-typescript": "^13.0.0",
 | 
			
		||||
    "@vue/test-utils": "^2.4.6",
 | 
			
		||||
    "@vue/tsconfig": "^0.5.1",
 | 
			
		||||
    "eslint": "^8.57.0",
 | 
			
		||||
    "eslint-plugin-vue": "^9.23.0",
 | 
			
		||||
    "jsdom": "^24.1.0",
 | 
			
		||||
    "npm-run-all2": "^6.2.0",
 | 
			
		||||
    "prettier": "^3.2.5",
 | 
			
		||||
    "typescript": "~5.4.0",
 | 
			
		||||
    "vite": "^5.3.1",
 | 
			
		||||
    "vite-plugin-vue-devtools": "^7.3.1",
 | 
			
		||||
    "vitest": "^1.6.0",
 | 
			
		||||
    "vue-tsc": "^2.0.21"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 4.2 KiB  | 
							
								
								
									
										111
									
								
								web/src/App.vue
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								web/src/App.vue
									
									
									
									
									
								
							@ -1,111 +0,0 @@
 | 
			
		||||
<!--
 | 
			
		||||
 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.
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { RouterLink, RouterView } from 'vue-router'
 | 
			
		||||
import HelloWorld from './components/HelloWorld.vue'
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <header>
 | 
			
		||||
    <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
 | 
			
		||||
 | 
			
		||||
    <div class="wrapper">
 | 
			
		||||
      <HelloWorld msg="You did it!" />
 | 
			
		||||
 | 
			
		||||
      <nav>
 | 
			
		||||
        <RouterLink to="/">Home</RouterLink>
 | 
			
		||||
        <RouterLink to="/about">About</RouterLink>
 | 
			
		||||
        <RouterLink to="/login">Login</RouterLink>
 | 
			
		||||
        <RouterLink to="/Signup">Signup</RouterLink>
 | 
			
		||||
      </nav>
 | 
			
		||||
    </div>
 | 
			
		||||
  </header>
 | 
			
		||||
 | 
			
		||||
  <RouterView />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
header {
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
  max-height: 100vh;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.logo {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: 0 auto 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  margin-top: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav a.router-link-exact-active {
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav a.router-link-exact-active:hover {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav a {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  padding: 0 1rem;
 | 
			
		||||
  border-left: 1px solid var(--color-border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav a:first-of-type {
 | 
			
		||||
  border: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 1024px) {
 | 
			
		||||
  header {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    place-items: center;
 | 
			
		||||
    padding-right: calc(var(--section-gap) / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .logo {
 | 
			
		||||
    margin: 0 2rem 0 0;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  header .wrapper {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    place-items: flex-start;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  nav {
 | 
			
		||||
    text-align: left;
 | 
			
		||||
    margin-left: -1rem;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
 | 
			
		||||
    padding: 1rem 0;
 | 
			
		||||
    margin-top: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@ -1,110 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/* color palette from <https://github.com/vuejs/theme> */
 | 
			
		||||
:root {
 | 
			
		||||
  --vt-c-white: #ffffff;
 | 
			
		||||
  --vt-c-white-soft: #f8f8f8;
 | 
			
		||||
  --vt-c-white-mute: #f2f2f2;
 | 
			
		||||
 | 
			
		||||
  --vt-c-black: #181818;
 | 
			
		||||
  --vt-c-black-soft: #222222;
 | 
			
		||||
  --vt-c-black-mute: #282828;
 | 
			
		||||
 | 
			
		||||
  --vt-c-indigo: #2c3e50;
 | 
			
		||||
 | 
			
		||||
  --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
 | 
			
		||||
  --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
 | 
			
		||||
  --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
 | 
			
		||||
  --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
 | 
			
		||||
 | 
			
		||||
  --vt-c-text-light-1: var(--vt-c-indigo);
 | 
			
		||||
  --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
 | 
			
		||||
  --vt-c-text-dark-1: var(--vt-c-white);
 | 
			
		||||
  --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* semantic color variables for this project */
 | 
			
		||||
:root {
 | 
			
		||||
  --color-background: var(--vt-c-white);
 | 
			
		||||
  --color-background-soft: var(--vt-c-white-soft);
 | 
			
		||||
  --color-background-mute: var(--vt-c-white-mute);
 | 
			
		||||
 | 
			
		||||
  --color-border: var(--vt-c-divider-light-2);
 | 
			
		||||
  --color-border-hover: var(--vt-c-divider-light-1);
 | 
			
		||||
 | 
			
		||||
  --color-heading: var(--vt-c-text-light-1);
 | 
			
		||||
  --color-text: var(--vt-c-text-light-1);
 | 
			
		||||
 | 
			
		||||
  --section-gap: 160px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (prefers-color-scheme: dark) {
 | 
			
		||||
  :root {
 | 
			
		||||
    --color-background: var(--vt-c-black);
 | 
			
		||||
    --color-background-soft: var(--vt-c-black-soft);
 | 
			
		||||
    --color-background-mute: var(--vt-c-black-mute);
 | 
			
		||||
 | 
			
		||||
    --color-border: var(--vt-c-divider-dark-2);
 | 
			
		||||
    --color-border-hover: var(--vt-c-divider-dark-1);
 | 
			
		||||
 | 
			
		||||
    --color-heading: var(--vt-c-text-dark-1);
 | 
			
		||||
    --color-text: var(--vt-c-text-dark-2);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
*,
 | 
			
		||||
*::before,
 | 
			
		||||
*::after {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  background: var(--color-background);
 | 
			
		||||
  transition:
 | 
			
		||||
    color 0.5s,
 | 
			
		||||
    background-color 0.5s;
 | 
			
		||||
  line-height: 1.6;
 | 
			
		||||
  font-family:
 | 
			
		||||
    Inter,
 | 
			
		||||
    -apple-system,
 | 
			
		||||
    BlinkMacSystemFont,
 | 
			
		||||
    'Segoe UI',
 | 
			
		||||
    Roboto,
 | 
			
		||||
    Oxygen,
 | 
			
		||||
    Ubuntu,
 | 
			
		||||
    Cantarell,
 | 
			
		||||
    'Fira Sans',
 | 
			
		||||
    'Droid Sans',
 | 
			
		||||
    'Helvetica Neue',
 | 
			
		||||
    sans-serif;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  text-rendering: optimizeLegibility;
 | 
			
		||||
  -webkit-font-smoothing: antialiased;
 | 
			
		||||
  -moz-osx-font-smoothing: grayscale;
 | 
			
		||||
}
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 276 B  | 
@ -1,59 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
@import './base.css';
 | 
			
		||||
 | 
			
		||||
#app {
 | 
			
		||||
  max-width: 1280px;
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
  font-weight: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a,
 | 
			
		||||
.green {
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  color: hsla(160, 100%, 37%, 1);
 | 
			
		||||
  transition: 0.4s;
 | 
			
		||||
  padding: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (hover: hover) {
 | 
			
		||||
  a:hover {
 | 
			
		||||
    background-color: hsla(160, 100%, 37%, 0.2);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 1024px) {
 | 
			
		||||
  body {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    place-items: center;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  #app {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: 1fr 1fr;
 | 
			
		||||
    padding: 0 2rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,65 +0,0 @@
 | 
			
		||||
<!--
 | 
			
		||||
 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.
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
defineProps<{
 | 
			
		||||
  msg: string
 | 
			
		||||
}>()
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="greetings">
 | 
			
		||||
    <h1 class="green">{{ msg }}</h1>
 | 
			
		||||
    <h3>
 | 
			
		||||
      You’ve successfully created a project with
 | 
			
		||||
      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
 | 
			
		||||
      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
 | 
			
		||||
    </h3>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
h1 {
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  font-size: 2.6rem;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  top: -10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
  font-size: 1.2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.greetings h1,
 | 
			
		||||
.greetings h3 {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 1024px) {
 | 
			
		||||
  .greetings h1,
 | 
			
		||||
  .greetings h3 {
 | 
			
		||||
    text-align: left;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@ -1,112 +0,0 @@
 | 
			
		||||
<!--
 | 
			
		||||
 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.
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import WelcomeItem from './WelcomeItem.vue'
 | 
			
		||||
import DocumentationIcon from './icons/IconDocumentation.vue'
 | 
			
		||||
import ToolingIcon from './icons/IconTooling.vue'
 | 
			
		||||
import EcosystemIcon from './icons/IconEcosystem.vue'
 | 
			
		||||
import CommunityIcon from './icons/IconCommunity.vue'
 | 
			
		||||
import SupportIcon from './icons/IconSupport.vue'
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <WelcomeItem>
 | 
			
		||||
    <template #icon>
 | 
			
		||||
      <DocumentationIcon />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #heading>Documentation</template>
 | 
			
		||||
 | 
			
		||||
    Vue’s
 | 
			
		||||
    <a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
 | 
			
		||||
    provides you with all information you need to get started.
 | 
			
		||||
  </WelcomeItem>
 | 
			
		||||
 | 
			
		||||
  <WelcomeItem>
 | 
			
		||||
    <template #icon>
 | 
			
		||||
      <ToolingIcon />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #heading>Tooling</template>
 | 
			
		||||
 | 
			
		||||
    This project is served and bundled with
 | 
			
		||||
    <a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
 | 
			
		||||
    recommended IDE setup is
 | 
			
		||||
    <a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
 | 
			
		||||
    <a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
 | 
			
		||||
    you need to test your components and web pages, check out
 | 
			
		||||
    <a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
 | 
			
		||||
    <a href="https://on.cypress.io/component" target="_blank" rel="noopener"
 | 
			
		||||
      >Cypress Component Testing</a
 | 
			
		||||
    >.
 | 
			
		||||
 | 
			
		||||
    <br />
 | 
			
		||||
 | 
			
		||||
    More instructions are available in <code>README.md</code>.
 | 
			
		||||
  </WelcomeItem>
 | 
			
		||||
 | 
			
		||||
  <WelcomeItem>
 | 
			
		||||
    <template #icon>
 | 
			
		||||
      <EcosystemIcon />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #heading>Ecosystem</template>
 | 
			
		||||
 | 
			
		||||
    Get official tools and libraries for your project:
 | 
			
		||||
    <a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
 | 
			
		||||
    <a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
 | 
			
		||||
    <a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
 | 
			
		||||
    <a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
 | 
			
		||||
    you need more resources, we suggest paying
 | 
			
		||||
    <a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
 | 
			
		||||
    a visit.
 | 
			
		||||
  </WelcomeItem>
 | 
			
		||||
 | 
			
		||||
  <WelcomeItem>
 | 
			
		||||
    <template #icon>
 | 
			
		||||
      <CommunityIcon />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #heading>Community</template>
 | 
			
		||||
 | 
			
		||||
    Got stuck? Ask your question on
 | 
			
		||||
    <a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
 | 
			
		||||
    Discord server, or
 | 
			
		||||
    <a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
 | 
			
		||||
      >StackOverflow</a
 | 
			
		||||
    >. You should also subscribe to
 | 
			
		||||
    <a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
 | 
			
		||||
    the official
 | 
			
		||||
    <a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
 | 
			
		||||
    twitter account for latest news in the Vue world.
 | 
			
		||||
  </WelcomeItem>
 | 
			
		||||
 | 
			
		||||
  <WelcomeItem>
 | 
			
		||||
    <template #icon>
 | 
			
		||||
      <SupportIcon />
 | 
			
		||||
    </template>
 | 
			
		||||
    <template #heading>Support Vue</template>
 | 
			
		||||
 | 
			
		||||
    As an independent project, Vue relies on community backing for its sustainability. You can help
 | 
			
		||||
    us by
 | 
			
		||||
    <a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
 | 
			
		||||
  </WelcomeItem>
 | 
			
		||||
</template>
 | 
			
		||||
@ -1,111 +0,0 @@
 | 
			
		||||
<!--
 | 
			
		||||
 MIT License
 | 
			
		||||
 | 
			
		||||
 Copyright (c) 2024 vinchent <vinchent@vinchent.xyz>
 | 
			
		||||
 | 
			
		||||
 Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
 of this software and associated documentation files (the "Software"), to deal
 | 
			
		||||
 in the Software without restriction, including without limitation the rights
 | 
			
		||||
 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 | 
			
		||||
 copies of the Software, and to permit persons to whom the Software is
 | 
			
		||||
 furnished to do so, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
 The above copyright notice and this permission notice shall be included in all
 | 
			
		||||
 copies or substantial portions of the Software.
 | 
			
		||||
 | 
			
		||||
 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 | 
			
		||||
 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 | 
			
		||||
 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 | 
			
		||||
 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 | 
			
		||||
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 | 
			
		||||
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
 SOFTWARE.
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="item">
 | 
			
		||||
    <i>
 | 
			
		||||
      <slot name="icon"></slot>
 | 
			
		||||
    </i>
 | 
			
		||||
    <div class="details">
 | 
			
		||||
      <h3>
 | 
			
		||||
        <slot name="heading"></slot>
 | 
			
		||||
      </h3>
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.item {
 | 
			
		||||
  margin-top: 2rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.details {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  margin-left: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
i {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  place-items: center;
 | 
			
		||||
  place-content: center;
 | 
			
		||||
  width: 32px;
 | 
			
		||||
  height: 32px;
 | 
			
		||||
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h3 {
 | 
			
		||||
  font-size: 1.2rem;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  margin-bottom: 0.4rem;
 | 
			
		||||
  color: var(--color-heading);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (min-width: 1024px) {
 | 
			
		||||
  .item {
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
    padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  i {
 | 
			
		||||
    top: calc(50% - 25px);
 | 
			
		||||
    left: -26px;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    border: 1px solid var(--color-border);
 | 
			
		||||
    background: var(--color-background);
 | 
			
		||||
    border-radius: 8px;
 | 
			
		||||
    width: 50px;
 | 
			
		||||
    height: 50px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .item:before {
 | 
			
		||||
    content: ' ';
 | 
			
		||||
    border-left: 1px solid var(--color-border);
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    bottom: calc(50% + 25px);
 | 
			
		||||
    height: calc(50% - 25px);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .item:after {
 | 
			
		||||
    content: ' ';
 | 
			
		||||
    border-left: 1px solid var(--color-border);
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    top: calc(50% + 25px);
 | 
			
		||||
    height: calc(50% - 25px);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .item:first-of-type:before {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .item:last-of-type:after {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@ -1,35 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { describe, it, expect } from 'vitest'
 | 
			
		||||
 | 
			
		||||
import { mount } from '@vue/test-utils'
 | 
			
		||||
import HelloWorld from '../HelloWorld.vue'
 | 
			
		||||
 | 
			
		||||
describe('HelloWorld', () => {
 | 
			
		||||
  it('renders properly', () => {
 | 
			
		||||
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
 | 
			
		||||
    expect(wrapper.text()).toContain('Hello Vitest')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user