Compare commits
125
Commits
619f613052
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee2af480d | ||
|
|
c0ff985061 | ||
|
|
616adbbf75 | ||
|
|
2c97ca7641 | ||
|
|
fd50ce99a7 | ||
|
|
7def43fbeb | ||
|
|
7821009f96 | ||
|
|
c4ce8e66b6 | ||
|
|
a9328043b7 | ||
|
|
3b3afedd64 | ||
|
|
469222054d | ||
|
|
0bb875020b | ||
|
|
726c3554eb | ||
|
|
c8a5da61ef | ||
|
|
77a4ccc520 | ||
|
|
4a967ef002 | ||
|
|
366386c8bf | ||
|
|
f81eb3be96 | ||
|
|
560695c067 | ||
|
|
9d8ed99827 | ||
|
|
26e5620fc2 | ||
|
|
3f254c289c | ||
|
|
934e231ba7 | ||
|
|
1715e87c5b | ||
|
|
f409f02f91 | ||
|
|
52cc00ebfd | ||
|
|
567f493ee5 | ||
|
|
b8d2c96821 | ||
|
|
495ec3cc6c | ||
|
|
3bc7bc6251 | ||
|
|
12e92e56bc | ||
|
|
28c83f1d08 | ||
|
|
571f9cdcfc | ||
|
|
e7ce3afe87 | ||
|
|
18a75e12f5 | ||
|
|
4697435fc9 | ||
|
|
e1f274b744 | ||
|
|
f0304cda1a | ||
|
|
4cb03c6712 | ||
|
|
ad8a5e3bab | ||
|
|
ba9c1bc14f | ||
|
|
6b100e02af | ||
|
|
2c50d75f88 | ||
|
|
3778ab7d58 | ||
|
|
62a5324e6d | ||
|
|
19ad59ac30 | ||
|
|
b17c2b6670 | ||
|
|
42fbd681ba | ||
|
|
c6498e1078 | ||
|
|
8e126c46fb | ||
|
|
24bb87fdca | ||
|
|
b4ab9eaba1 | ||
|
|
173d56d399 | ||
|
|
d3aa9523dc | ||
|
|
26aa0c2a94 | ||
|
|
b35dc4841f | ||
|
|
4b703b077c | ||
|
|
2e3e82e776 | ||
|
|
f87b0a0707 | ||
|
|
1695a57dbd | ||
|
|
02931d16be | ||
|
|
4bcdc98edd | ||
|
|
3a07ffceb1 | ||
|
|
69379f3a6a | ||
|
|
8479b8ac4b | ||
|
|
4b91e7b32c | ||
|
|
47a6ad6cbf | ||
|
|
2ae4dbdc11 | ||
|
|
8b0d82ac24 | ||
|
|
755ebefd30 | ||
|
|
8ab2f3f1bf | ||
|
|
e52b7d4fad | ||
|
|
5cfeb0bf96 | ||
|
|
2e16eefaba | ||
|
|
491b8da1ac | ||
|
|
eaf676f8b4 | ||
|
|
73df97548b | ||
|
|
a5fba4183b | ||
|
|
da3b2a5a6e | ||
|
|
68d45db8cd | ||
|
|
dc71ff4332 | ||
|
|
67f5d43a58 | ||
|
|
ac527e1928 | ||
|
|
ac00c791af | ||
|
|
c468dac5e1 | ||
|
|
c466eb59bc | ||
|
|
3360687a00 | ||
|
|
655f99f166 | ||
|
|
ce73cb451a | ||
|
|
06846c611d | ||
|
|
62b3863257 | ||
|
|
95da21a84f | ||
|
|
7f98fedaa6 | ||
|
|
d0de5b2982 | ||
|
|
ac2e66b731 | ||
|
|
78bc7341fc | ||
|
|
cf10fda69b | ||
|
|
9f79a5d73b | ||
|
|
667811a88a | ||
|
|
efc3523579 | ||
|
|
a405816386 | ||
|
|
dac38bd0a1 | ||
|
|
d68231c4c1 | ||
|
|
817c845e3b | ||
|
|
a4a84bed68 | ||
|
|
e8405e31a8 | ||
|
|
8eb70a9a63 | ||
|
|
a7d3d6c378 | ||
|
|
7fdf1ed925 | ||
|
|
2732038a1f | ||
|
|
f031f7c15a | ||
|
|
c677b71a7a | ||
|
|
2b0f2acb96 | ||
|
|
deee3e0d4f | ||
|
|
ff0fe5ff13 | ||
|
|
b777e00d94 | ||
|
|
f1ea27092f | ||
|
|
c2912d8d79 | ||
|
|
a1ec7cc24a | ||
|
|
be588dd405 | ||
|
|
d2f0f1a4cf | ||
|
|
3c13e13c2e | ||
|
|
3c5c8a7fe7 | ||
|
|
eb45deda18 | ||
|
|
da7b6a7e78 |
@@ -0,0 +1,68 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
# Required secrets
|
||||||
|
# DEPLOY_SSH_KEY - private key for the deploy user
|
||||||
|
# DEPLOY_HOST - production host, e.g. YOUR_PROD_HOST
|
||||||
|
# DEPLOY_USER - ssh user, must be in the `docker` group
|
||||||
|
# DEPLOY_PATH - destination dir, e.g. /opt/dttmr-api
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends openssh-client
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
env:
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"cd $DEPLOY_PATH && git pull && docker compose up -d --build"
|
||||||
|
|
||||||
|
sync-dev:
|
||||||
|
needs: build-and-deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends git
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fast-forward dev to main
|
||||||
|
run: |
|
||||||
|
git fetch origin dev:dev
|
||||||
|
git checkout dev
|
||||||
|
git merge --ff-only origin/main
|
||||||
|
git push origin dev
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: PR Checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.27-bookworm
|
||||||
|
volumes:
|
||||||
|
- go-mod-cache:/go/pkg/mod
|
||||||
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends curl
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "Not gofmt'd:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download modules
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: make lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: make build
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.27-alpine AS build
|
||||||
|
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ run: build-api
|
|||||||
## test: Run tests with race detector
|
## test: Run tests with race detector
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@echo "Running tests..."
|
||||||
@go test -v -race -timeout 30s ./...
|
@if [ "$$(go env GOARCH)" = "amd64" ]; then \
|
||||||
|
go test -race -timeout 30s ./...; \
|
||||||
|
else \
|
||||||
|
go test -timeout 60s ./...; \
|
||||||
|
fi
|
||||||
|
|
||||||
## test-cover: Run tests and generate coverage report
|
## test-cover: Run tests and generate coverage report
|
||||||
test-cover:
|
test-cover:
|
||||||
@@ -60,7 +64,8 @@ test-cover:
|
|||||||
## lint: Run golangci-lint
|
## lint: Run golangci-lint
|
||||||
lint:
|
lint:
|
||||||
@echo "Running linter..."
|
@echo "Running linter..."
|
||||||
@golangci-lint run ./...
|
@#golangci-lint run ./...
|
||||||
|
@go vet ./...
|
||||||
|
|
||||||
## fmt: Format code and organize imports
|
## fmt: Format code and organize imports
|
||||||
fmt:
|
fmt:
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
### POST login
|
||||||
|
POST http://localhost:8080/api/v1/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "12345"
|
||||||
|
}
|
||||||
|
|
||||||
|
> {% client.global.set("access_token", response.body.access_token); %}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:8080/api/v1/lists
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "test_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:8080/api/v1/lists
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
GET http://localhost:8080/api/v1/lists/e817465a-d75d-420b-9d4a-610496ed3bc7
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:8080/api/v1/lists/item
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"list_id": "cd2c280a-05fa-4ea0-b350-0d3f972b9777",
|
||||||
|
"title": "hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
PUT http://localhost:8080/api/v1/lists/item
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"list_item_id": "3b0666b0-bfc5-41e2-8c22-e3437da40298",
|
||||||
|
"title": "hello 2",
|
||||||
|
"is_completed": true
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
POST http://localhost:8080/api/v1/lists/items/3b0666b0-bfc5-41e2-8c22-e3437da40298
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{access_token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"is_completed": false
|
||||||
|
}
|
||||||
+8
-5
@@ -12,11 +12,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/api/router"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/config"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/database"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/telemetry"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/router"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/config"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/database"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/telemetry"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -74,7 +74,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
shutdownTelemetry, err := telemetry.Init(context.Background(), telCfg)
|
shutdownTelemetry, err := telemetry.Init(context.Background(), telCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to initialize telemetry", err)
|
slog.Error("failed to initialize telemetry", slog.Any("error", err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -138,6 +138,9 @@ func makeServer(db *sql.DB, cfg *config.Config) *http.Server {
|
|||||||
routerConfig := router.Config{
|
routerConfig := router.Config{
|
||||||
Database: db,
|
Database: db,
|
||||||
JWTSecret: cfg.JWTSecret,
|
JWTSecret: cfg.JWTSecret,
|
||||||
|
ServiceVersion: Version,
|
||||||
|
ServiceCommit: Commit,
|
||||||
|
ServiceBuildTime: BuildTime,
|
||||||
}
|
}
|
||||||
mux := router.NewMux(routerConfig)
|
mux := router.NewMux(routerConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/config"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/database"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/database/migrations"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/repository"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/robindittmar/dttmr-api/internal/config"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/database"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -51,7 +52,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := database.RunMigrations(db); err != nil {
|
if err := database.RunMigrations(db, migrations.MigrationFS); err != nil {
|
||||||
slog.Error("failed to run migrations", slog.Any("error", err))
|
slog.Error("failed to run migrations", slog.Any("error", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -69,8 +70,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seedAdminUser(db *sql.DB, email string, name string, password string) error {
|
func seedAdminUser(db *sql.DB, email string, name string, password string) error {
|
||||||
userRepo := repository.NewUserRepo(db)
|
store := repository.NewStore(db)
|
||||||
userService := domain.NewUserService(userRepo)
|
userService := domain.NewUserService(store.User)
|
||||||
|
|
||||||
_, err := userService.CreateUser(context.Background(), email, name, password)
|
_, err := userService.CreateUser(context.Background(), email, name, password)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
module github.com/robindittmar/dttmr-api
|
module git.dittmar.dev/robin/dttmr-api
|
||||||
|
|
||||||
go 1.26
|
go 1.27
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.20.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
|
||||||
go.opentelemetry.io/otel v1.45.0
|
go.opentelemetry.io/otel v1.46.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
|
||||||
go.opentelemetry.io/otel/sdk v1.45.0
|
go.opentelemetry.io/otel/sdk v1.46.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.45.0
|
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||||
go.opentelemetry.io/otel/trace v1.45.0
|
go.opentelemetry.io/otel/trace v1.46.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
@@ -32,19 +31,18 @@ require (
|
|||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lib/pq v1.12.3 // indirect
|
github.com/lib/pq v1.12.3 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/net v0.59.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 // indirect
|
||||||
google.golang.org/grpc v1.83.1 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect
|
||||||
|
google.golang.org/grpc v1.83.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.12 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
|
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||||
|
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
|
||||||
|
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||||
|
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||||
|
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
|
||||||
|
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 h1:NCe/UiklGd/9xjT+ROBVhJ1kf6TRQaFedsR+z7u1gvo=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 h1:O4Tjo2vlGeGM3+tNgwJrGj5fQHO2V71gDkFqbm328C8=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:r4KD2hOq82JBWpTWkJ9NZLf6EwmRvAPIFmk7hPNtd+0=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
|
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||||
|
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/request"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
@@ -27,6 +28,8 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler {
|
|||||||
// @Param payload body request.LoginPayload true "Login payload"
|
// @Param payload body request.LoginPayload true "Login payload"
|
||||||
// @Success 200 {object} domain.TokenPair
|
// @Success 200 {object} domain.TokenPair
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 401 {object} response.ErrorResponse "email not found"
|
||||||
|
// @Error 401 {object} response.ErrorResponse "password is wrong"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to login"
|
// @Error 500 {object} response.ErrorResponse "failed to login"
|
||||||
// @Router /login [post]
|
// @Router /login [post]
|
||||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -41,8 +44,15 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tokens, err := h.AuthService.Login(ctx, payload.Email, payload.Password)
|
tokens, err := h.AuthService.Login(ctx, payload.Email, payload.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to login", slog.Any("error", err))
|
if errors.Is(err, domain.ErrEmailNotFound) {
|
||||||
|
response.Error(ctx, w, http.StatusUnauthorized, "email not found")
|
||||||
|
} else if errors.Is(err, domain.ErrPasswordWrong) {
|
||||||
|
response.Error(ctx, w, http.StatusUnauthorized, "password is wrong")
|
||||||
|
} else {
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.ErrorContext(ctx, "failed to login", slog.Any("error", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Tags Authorization
|
// @Tags Authorization
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
// @Param payload body request.LogoutPayload true "Logout payload"
|
||||||
// @Success 200 {object} nil
|
// @Success 200 {object} nil
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to logout"
|
// @Error 500 {object} response.ErrorResponse "failed to logout"
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type apiResponse struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Proto string `json:"proto"`
|
|
||||||
Header map[string]string `json:"header"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
RemoteAddr string `json:"remoteAddr"`
|
|
||||||
Form map[string]string `json:"form"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultHandler handles the default route
|
|
||||||
//
|
|
||||||
// @Summary Default route handler
|
|
||||||
// @Description Default route handler
|
|
||||||
// @Tags
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} apiResponse
|
|
||||||
// @Router / [get]
|
|
||||||
func DefaultHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
resp := apiResponse{
|
|
||||||
Method: r.Method,
|
|
||||||
Url: r.URL.String(),
|
|
||||||
Proto: r.Proto,
|
|
||||||
Header: make(map[string]string),
|
|
||||||
Host: r.Host,
|
|
||||||
RemoteAddr: r.RemoteAddr,
|
|
||||||
Form: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range r.Header {
|
|
||||||
resp.Header[k] = v[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.ParseForm(); err == nil {
|
|
||||||
for k, v := range r.Form {
|
|
||||||
resp.Form[k] = v[0]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.ErrorContext(ctx, "error parsing form", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
response.JSON(ctx, w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExerciseHandler struct {
|
||||||
|
ExerciseService *domain.ExerciseService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExerciseHandler(exerciseService *domain.ExerciseService) *ExerciseHandler {
|
||||||
|
return &ExerciseHandler{ExerciseService: exerciseService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExercises handles fetching the list of exercises
|
||||||
|
//
|
||||||
|
// @Summary Get exercises route
|
||||||
|
// @Description Gets a list of all exercises
|
||||||
|
// @Tags Exercise
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param page query int false "page"
|
||||||
|
// @Param count query int false "count"
|
||||||
|
// @Success 200 {object} response.Paginated[domain.Exercise]
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invalid value for page"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invalid value for count"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to get exercises"
|
||||||
|
// @Router /exercises [get]
|
||||||
|
func (h *ExerciseHandler) GetExercises(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
page, count, err := request.ParsePaginatedQueryParams(r)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exercises, err := h.ExerciseService.GetExercises(ctx, page, count)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get exercises", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := h.ExerciseService.CountExercises(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to count exercises", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get exercises")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Exercise]{
|
||||||
|
Count: len(exercises),
|
||||||
|
Total: total,
|
||||||
|
Data: exercises,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
type healthResponse struct {
|
type healthResponse struct {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/handler"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/handler"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthHandler_Success(t *testing.T) {
|
func TestHealthHandler_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InviteHandler struct {
|
||||||
|
InviteService *domain.InviteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInviteHandler(inviteService *domain.InviteService) *InviteHandler {
|
||||||
|
return &InviteHandler{InviteService: inviteService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvite handles the creation of an invitation
|
||||||
|
//
|
||||||
|
// @Summary Create invite route
|
||||||
|
// @Description Create an invitation, which may be used to register an account
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 201 {object} domain.Invite
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to create invite"
|
||||||
|
// @Router /user/invites [post]
|
||||||
|
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invite, err := h.InviteService.CreateInvite(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to create invite", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "created invite successfully", slog.String("invite_id", invite.ID))
|
||||||
|
response.JSON(ctx, w, http.StatusCreated, invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvite handles the deletion of an invitation
|
||||||
|
//
|
||||||
|
// @Summary Delete invitation route
|
||||||
|
// @Description Deletes an invitation. Invitations can only be deleted when they have not been used
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Invite ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to delete invite"
|
||||||
|
// @Router /user/invites/{id} [delete]
|
||||||
|
func (h *InviteHandler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
inviteID := r.PathValue("id")
|
||||||
|
if inviteID == "" {
|
||||||
|
slog.ErrorContext(ctx, "failed to read invite id from path")
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.InviteService.DeleteInvite(ctx, authContext.UserID, inviteID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to delete invite", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "deleted invite successfully", slog.String("invite_id", inviteID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvites handles fetching the list of a users invitations
|
||||||
|
//
|
||||||
|
// @Summary Get invitations route
|
||||||
|
// @Description Gets a list of all invitations the user has created
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param page query int false "page"
|
||||||
|
// @Param count query int false "count"
|
||||||
|
// @Success 200 {object} response.Paginated[domain.Invite]
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invalid value for page"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invalid value for count"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to get invites"
|
||||||
|
// @Router /user/invites [get]
|
||||||
|
func (h *InviteHandler) GetInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
if pageStr == "" {
|
||||||
|
pageStr = "1"
|
||||||
|
}
|
||||||
|
countStr := r.URL.Query().Get("count")
|
||||||
|
if countStr == "" {
|
||||||
|
countStr = "10"
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"failed to read page from query",
|
||||||
|
slog.String("page", pageStr))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := strconv.Atoi(countStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"failed to read count from query",
|
||||||
|
slog.String("count", countStr))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"page parameter is invalid",
|
||||||
|
slog.Int("page", page))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "invalid value for page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"count parameter is invalid",
|
||||||
|
slog.Int("count", count))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "invalid value for count")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invites, err := h.InviteService.GetInvites(ctx, authContext.UserID, page, count)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get invites", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := h.InviteService.CountInvites(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to count invites", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.JSON(ctx, w, http.StatusOK, response.Paginated[domain.Invite]{
|
||||||
|
Count: len(invites),
|
||||||
|
Total: total,
|
||||||
|
Data: invites,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvitesStatus handles fetching the counts of a users invitations
|
||||||
|
//
|
||||||
|
// @Summary Get invitations status
|
||||||
|
// @Description Gets active/expired/used counts for all the users invites
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} domain.InviteCounts
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to count invites"
|
||||||
|
// @Router /user/invites [get]
|
||||||
|
|
||||||
|
func (h *InviteHandler) GetInvitesStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to count invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := h.InviteService.CountInvitesStructured(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to count invites", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to count invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.JSON(ctx, w, http.StatusOK, counts)
|
||||||
|
}
|
||||||
+224
-23
@@ -1,20 +1,22 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/request"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListHandler struct {
|
type ListHandler struct {
|
||||||
ListService *domain.ListService
|
ListService *domain.ListService
|
||||||
|
UserService *domain.UserService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListHandler(listService *domain.ListService) *ListHandler {
|
func NewListHandler(listService *domain.ListService, userService *domain.UserService) *ListHandler {
|
||||||
return &ListHandler{ListService: listService}
|
return &ListHandler{ListService: listService, UserService: userService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateList handles the creation of a list
|
// CreateList handles the creation of a list
|
||||||
@@ -46,17 +48,57 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
list, err := h.ListService.Create(ctx, authContext.UserID, payload.Name, payload.UserIDs)
|
list, err := h.ListService.CreateList(ctx, authContext.UserID, payload.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err))
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list")
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created list successfully", slog.Any("list_id", list.ID))
|
slog.InfoContext(ctx, "created list successfully", slog.String("list_id", list.ID))
|
||||||
response.JSON(ctx, w, http.StatusCreated, list)
|
response.JSON(ctx, w, http.StatusCreated, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteList handles the deletion of a list
|
||||||
|
//
|
||||||
|
// @Summary Delete list route
|
||||||
|
// @Description Deletes a list, cascading to user associations and items
|
||||||
|
// @Tags List
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "List ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to delete list"
|
||||||
|
// @Router /lists/{id} [delete]
|
||||||
|
func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
listID := r.PathValue("id")
|
||||||
|
if listID == "" {
|
||||||
|
slog.ErrorContext(ctx, "failed to read list id from path")
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.DeleteList(ctx, authContext.UserID, listID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to delete list", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "deleted list successfully", slog.String("list_id", listID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// GetLists handles fetching lists for the current user
|
// GetLists handles fetching lists for the current user
|
||||||
//
|
//
|
||||||
// @Summary Returns all lists of the user
|
// @Summary Returns all lists of the user
|
||||||
@@ -81,7 +123,7 @@ func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
lists, err := h.ListService.GetLists(ctx, authContext.UserID)
|
lists, err := h.ListService.GetLists(ctx, authContext.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to set list item completed", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to get lists", slog.Any("error", err))
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to read list items")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,6 +141,7 @@ func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param payload body request.AddUserToListPayload true "Add user to list payload"
|
// @Param payload body request.AddUserToListPayload true "Add user to list payload"
|
||||||
// @Success 204 {object} nil
|
// @Success 204 {object} nil
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to find email in system"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to add user to list"
|
// @Error 500 {object} response.ErrorResponse "failed to add user to list"
|
||||||
// @Router /lists/user [post]
|
// @Router /lists/user [post]
|
||||||
func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -118,15 +161,25 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.ListService.AddUserToList(ctx, authContext.UserID, payload.ListID, payload.UserID)
|
user, err := h.UserService.GetUserByEmail(ctx, payload.Email)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get user by email", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to find email in system")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.AddUserToList(ctx, authContext.UserID, payload.ListID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to add user to list", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to add user to list", slog.Any("error", err))
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to add user to list")
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to add user to list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "added user to list successfully", slog.Any("list_id", payload.ListID), slog.Any("user_id", payload.UserID))
|
slog.InfoContext(ctx, "added user to list successfully",
|
||||||
response.Status(ctx, w, http.StatusNoContent)
|
slog.String("list_id", payload.ListID),
|
||||||
|
slog.String("email", user.Email),
|
||||||
|
)
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveUserFromList handles the removal of a user association to a list
|
// RemoveUserFromList handles the removal of a user association to a list
|
||||||
@@ -139,6 +192,7 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param payload body request.RemoveUserFromListPayload true "Remove user from list payload"
|
// @Param payload body request.RemoveUserFromListPayload true "Remove user from list payload"
|
||||||
// @Success 204 {object} nil
|
// @Success 204 {object} nil
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to find email in system"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to remove user from list"
|
// @Error 500 {object} response.ErrorResponse "failed to remove user from list"
|
||||||
// @Router /lists/user [delete]
|
// @Router /lists/user [delete]
|
||||||
func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -158,15 +212,71 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.ListService.RemoveUserFromList(ctx, authContext.UserID, payload.ListID, payload.UserID)
|
user, err := h.UserService.GetUserByEmail(ctx, payload.Email)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get user by email", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to find email in system")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.RemoveUserFromList(ctx, authContext.UserID, payload.ListID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to remove user from list", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to remove user from list", slog.Any("error", err))
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to remove user from list")
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to remove user from list")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "removed user from list successfully", slog.Any("list_id", payload.ListID), slog.Any("user_id", payload.UserID))
|
slog.InfoContext(ctx, "removed user from list successfully",
|
||||||
response.Status(ctx, w, http.StatusNoContent)
|
slog.String("list_id", payload.ListID),
|
||||||
|
slog.String("email", user.Email),
|
||||||
|
)
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderLists handles re-ordering a users lists
|
||||||
|
//
|
||||||
|
// @Summary Order lists of a user
|
||||||
|
// @Description Re-assigns the display order of all users lists
|
||||||
|
// @Tags List
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body request.OrderListsPayload true "Order lists payload"
|
||||||
|
// @Success 204 {object} nil
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 401 {object} response.ErrorResponse "not authorized"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to order lists"
|
||||||
|
// @Router /lists/order [post]
|
||||||
|
func (h *ListHandler) OrderLists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
payload, err := request.DecodeJSON[request.OrderListsPayload](r)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to decode order lists payload", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.OrderLists(ctx, authContext.UserID, payload.ListIDs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrStaleListIDs) {
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "stale list IDs")
|
||||||
|
} else {
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to order list items")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.ErrorContext(ctx, "failed to order lists", slog.Any("error", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "lists re-ordered successfully", slog.String("user_id", authContext.UserID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateListItem handles creation of a new list item on a given list
|
// CreateListItem handles creation of a new list item on a given list
|
||||||
@@ -180,7 +290,7 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
|
|||||||
// @Success 204 {object} nil
|
// @Success 204 {object} nil
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to create list item"
|
// @Error 500 {object} response.ErrorResponse "failed to create list item"
|
||||||
// @Router /lists/item [post]
|
// @Router /lists/items [post]
|
||||||
func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -205,10 +315,50 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created list item successfully", slog.Any("list_item_id", item.ID))
|
slog.InfoContext(ctx, "created list item successfully", slog.String("list_item_id", item.ID))
|
||||||
response.JSON(ctx, w, http.StatusCreated, item)
|
response.JSON(ctx, w, http.StatusCreated, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteListItem handles the deletion of a list item
|
||||||
|
//
|
||||||
|
// @Summary Delete list item route
|
||||||
|
// @Description Deletes an item
|
||||||
|
// @Tags List
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "List Item ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to delete list item"
|
||||||
|
// @Router /lists/items/{id} [delete]
|
||||||
|
func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
listItemID := r.PathValue("id")
|
||||||
|
if listItemID == "" {
|
||||||
|
slog.ErrorContext(ctx, "failed to read list item id from path")
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete list item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.DeleteListItem(ctx, authContext.UserID, listItemID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to delete list item", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete list item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "deleted list item successfully", slog.String("list_item_id", listItemID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateListItem handles updating of a list item
|
// UpdateListItem handles updating of a list item
|
||||||
//
|
//
|
||||||
// @Summary Update list item
|
// @Summary Update list item
|
||||||
@@ -221,7 +371,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
// @Error 401 {object} response.ErrorResponse "not authorized"
|
// @Error 401 {object} response.ErrorResponse "not authorized"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to update list item"
|
// @Error 500 {object} response.ErrorResponse "failed to update list item"
|
||||||
// @Router /lists/item [put]
|
// @Router /lists/items [put]
|
||||||
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -246,8 +396,58 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "updated list item successfully", slog.Any("list_item_id", payload.ListItemID))
|
slog.InfoContext(ctx, "updated list item successfully", slog.String("list_item_id", payload.ListItemID))
|
||||||
response.Status(ctx, w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetListItemTitle handles updating "title" of a list item
|
||||||
|
//
|
||||||
|
// @Summary Updates "title" of list item
|
||||||
|
// @Description Update an existing list item, setting the "title" field
|
||||||
|
// @Tags List
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "List Item ID"
|
||||||
|
// @Param payload body request.SetListItemTitlePayload true "Update list item title payload"
|
||||||
|
// @Success 204 {object} nil
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 401 {object} response.ErrorResponse "not authorized"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to set list item title"
|
||||||
|
// @Router /lists/items/{id}/title [post]
|
||||||
|
func (h *ListHandler) SetListItemTitle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
listItemID := r.PathValue("id")
|
||||||
|
if listItemID == "" {
|
||||||
|
slog.ErrorContext(ctx, "failed to read list item id from path")
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := request.DecodeJSON[request.SetListItemTitlePayload](r)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to decode set list item title payload", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusUnauthorized, "not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ListService.SetListItemTitle(ctx, authContext.UserID, listItemID, payload.Title)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to set list item title", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to set list item title")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "update list item title successful", slog.String("list_item_id", listItemID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetListItemCompleted handles updating "is_completed" of a list item
|
// SetListItemCompleted handles updating "is_completed" of a list item
|
||||||
@@ -257,13 +457,14 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Tags List
|
// @Tags List
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
// @Param id path int true "List Item ID"
|
||||||
// @Param payload body request.SetListItemCompletedPayload true "Update list item is completed payload"
|
// @Param payload body request.SetListItemCompletedPayload true "Update list item is completed payload"
|
||||||
// @Success 204 {object} nil
|
// @Success 204 {object} nil
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
// @Error 401 {object} response.ErrorResponse "not authorized"
|
// @Error 401 {object} response.ErrorResponse "not authorized"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to update list item"
|
// @Error 500 {object} response.ErrorResponse "failed to set list item completed"
|
||||||
// @Router /lists/items/{id} [post]
|
// @Router /lists/items/{id}/complete [post]
|
||||||
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
|
func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
@@ -295,8 +496,8 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "updated list item completed successful", slog.Any("list_item_id", listItemID))
|
slog.InfoContext(ctx, "update list item completed successful", slog.String("list_item_id", listItemID))
|
||||||
response.Status(ctx, w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetListItems handles return all list items of a list
|
// GetListItems handles return all list items of a list
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/request"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/request"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
UserService *domain.UserService
|
UserService *domain.UserService
|
||||||
|
AuthService *domain.AuthService
|
||||||
|
RegistrationService *domain.RegistrationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(userService *domain.UserService) *UserHandler {
|
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, registrationService *domain.RegistrationService) *UserHandler {
|
||||||
return &UserHandler{UserService: userService}
|
return &UserHandler{UserService: userService, AuthService: authService, RegistrationService: registrationService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser handles the creation of a user
|
// CreateUser handles the creation of a user
|
||||||
@@ -27,6 +30,9 @@ func NewUserHandler(userService *domain.UserService) *UserHandler {
|
|||||||
// @Param payload body request.CreateUserPayload true "Create user payload"
|
// @Param payload body request.CreateUserPayload true "Create user payload"
|
||||||
// @Success 201 {object} domain.User
|
// @Success 201 {object} domain.User
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invite is expired"
|
||||||
|
// @Error 409 {object} response.ErrorResponse "invite is already consumed"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invite is invalid"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to create user"
|
// @Error 500 {object} response.ErrorResponse "failed to create user"
|
||||||
// @Router /users [post]
|
// @Router /users [post]
|
||||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -39,13 +45,80 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password)
|
user, err := h.RegistrationService.Register(ctx, payload.InviteCode, payload.Email, payload.Name, payload.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to register user",
|
||||||
response.Error(ctx, w, http.StatusInternalServerError, "failed to create user")
|
slog.Any("error", err),
|
||||||
|
slog.Any("payload", payload))
|
||||||
|
|
||||||
|
if errors.Is(err, domain.ErrInviteExpired) {
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "invite is expired")
|
||||||
|
} else if errors.Is(err, domain.ErrInviteConsumed) {
|
||||||
|
response.Error(ctx, w, http.StatusConflict, "invite is already consumed")
|
||||||
|
} else if errors.Is(err, domain.ErrInviteInvalid) {
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "invite is invalid")
|
||||||
|
} else {
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to register")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID))
|
slog.InfoContext(ctx, "created user successfully",
|
||||||
|
slog.String("user_id", user.ID),
|
||||||
|
)
|
||||||
response.JSON(ctx, w, http.StatusCreated, user)
|
response.JSON(ctx, w, http.StatusCreated, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangePassword handles changing a users password
|
||||||
|
//
|
||||||
|
// @Summary Change password route
|
||||||
|
// @Description Change password for the authenticated user
|
||||||
|
// @Tags User
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body request.ChangePasswordPayload true "Change password payload"
|
||||||
|
// @Success 204
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 401 {object} response.ErrorResponse "could not authenticate with current password"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "could not get auth context"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to change password"
|
||||||
|
// @Router /user/password [post]
|
||||||
|
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
payload, err := request.DecodeJSON[request.ChangePasswordPayload](r)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to decode change password payload", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "could not get auth context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.AuthService.Authenticate(ctx, authContext.Email, payload.OldPassword)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to authenticate with current password", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusUnauthorized, "could not authenticate with current password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.UserService.ChangePassword(ctx, authContext.UserID, payload.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to change password", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to change password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.AuthService.LogoutAllDevices(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.WarnContext(ctx, "failed to logout user from all devices after password change", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "password changed successfully", slog.Any("user_id", authContext.UserID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type versionResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
BuildTime string `json:"buildTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionHandler handles the version route
|
||||||
|
//
|
||||||
|
// @Summary Service version
|
||||||
|
// @Description Reports the version of the API
|
||||||
|
// @Tags Version
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} versionResponse
|
||||||
|
// @Router /version [get]
|
||||||
|
func VersionHandler(version string, commit string, buildTime string) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
response.JSON(r.Context(), w, http.StatusOK, versionResponse{
|
||||||
|
Version: version,
|
||||||
|
Commit: commit,
|
||||||
|
BuildTime: buildTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/response"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func WithJWT(authService *domain.AuthService) func(http.HandlerFunc) http.HandlerFunc {
|
func WithJWT(authService *domain.AuthService) func(http.HandlerFunc) http.HandlerFunc {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package request
|
||||||
|
|
||||||
|
type GetInvitesPayload struct {
|
||||||
|
Page int
|
||||||
|
CountPerPage int
|
||||||
|
}
|
||||||
@@ -2,17 +2,20 @@ package request
|
|||||||
|
|
||||||
type CreateListPayload struct {
|
type CreateListPayload struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UserIDs []string `json:"user_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddUserToListPayload struct {
|
type AddUserToListPayload struct {
|
||||||
ListID string `json:"list_id"`
|
ListID string `json:"list_id"`
|
||||||
UserID string `json:"user_id"`
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoveUserFromListPayload struct {
|
type RemoveUserFromListPayload struct {
|
||||||
ListID string `json:"list_id"`
|
ListID string `json:"list_id"`
|
||||||
UserID string `json:"user_id"`
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderListsPayload struct {
|
||||||
|
ListIDs []string `json:"list_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateListItemPayload struct {
|
type CreateListItemPayload struct {
|
||||||
@@ -26,6 +29,10 @@ type UpdateListItemPayload struct {
|
|||||||
IsCompleted bool `json:"is_completed"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetListItemTitlePayload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
type SetListItemCompletedPayload struct {
|
type SetListItemCompletedPayload struct {
|
||||||
IsCompleted bool `json:"is_completed"`
|
IsCompleted bool `json:"is_completed"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package request
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFailedToDecodeRequestQuery = errors.New("failed to decode request query")
|
||||||
|
ErrInvalidPageValue = errors.New("invalid value for page")
|
||||||
|
ErrInvalidCountValue = errors.New("invalid value for count")
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParsePaginatedQueryParams(r *http.Request) (int, int, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
if pageStr == "" {
|
||||||
|
pageStr = "1"
|
||||||
|
}
|
||||||
|
countStr := r.URL.Query().Get("count")
|
||||||
|
if countStr == "" {
|
||||||
|
countStr = "10"
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"failed to read page from query",
|
||||||
|
slog.String("page", pageStr))
|
||||||
|
return 0, 0, ErrFailedToDecodeRequestQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := strconv.Atoi(countStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"failed to read count from query",
|
||||||
|
slog.String("count", countStr))
|
||||||
|
return 0, 0, ErrFailedToDecodeRequestQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"page parameter is invalid",
|
||||||
|
slog.Int("page", page))
|
||||||
|
return 0, 0, ErrInvalidPageValue
|
||||||
|
}
|
||||||
|
if count <= 0 {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"count parameter is invalid",
|
||||||
|
slog.Int("count", count))
|
||||||
|
return 0, 0, ErrInvalidCountValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, count, nil
|
||||||
|
}
|
||||||
@@ -4,4 +4,10 @@ type CreateUserPayload struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangePasswordPayload struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Status(w http.ResponseWriter, status int) {
|
||||||
|
w.WriteHeader(status)
|
||||||
|
}
|
||||||
@@ -2,16 +2,11 @@ package response
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json/v2"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: Move to own file
|
|
||||||
func Status(ctx context.Context, w http.ResponseWriter, status int) {
|
|
||||||
w.WriteHeader(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) {
|
func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) {
|
||||||
payload, err := json.Marshal(data)
|
payload, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package response
|
||||||
|
|
||||||
|
type Paginated[T any] struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Data []T `json:"data"`
|
||||||
|
}
|
||||||
@@ -4,52 +4,78 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/handler"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/handler"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/middleware"
|
"git.dittmar.dev/robin/dttmr-api/internal/api/middleware"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
"github.com/robindittmar/dttmr-api/internal/repository"
|
"git.dittmar.dev/robin/dttmr-api/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Database *sql.DB
|
Database *sql.DB
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
ServiceVersion string
|
||||||
|
ServiceCommit string
|
||||||
|
ServiceBuildTime string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMux(cfg Config) http.Handler {
|
func NewMux(cfg Config) http.Handler {
|
||||||
userRepo := repository.NewUserRepo(cfg.Database)
|
store := repository.NewStore(cfg.Database)
|
||||||
userService := domain.NewUserService(userRepo)
|
|
||||||
userHandler := handler.NewUserHandler(userService)
|
authService := domain.NewAuthService(store.Auth, []byte(cfg.JWTSecret))
|
||||||
|
inviteService := domain.NewInviteService(store.Invite)
|
||||||
|
userService := domain.NewUserService(store.User)
|
||||||
|
registrationService := domain.NewRegistrationService(store, userService, inviteService)
|
||||||
|
listService := domain.NewListService(store, store.List)
|
||||||
|
exerciseService := domain.NewExerciseService(store.Exercise)
|
||||||
|
|
||||||
authRepo := repository.NewAuthRepo(cfg.Database)
|
|
||||||
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
|
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
|
inviteHandler := handler.NewInviteHandler(inviteService)
|
||||||
listRepo := repository.NewListRepo(cfg.Database)
|
userHandler := handler.NewUserHandler(userService, authService, registrationService)
|
||||||
listService := domain.NewListService(listRepo)
|
listHandler := handler.NewListHandler(listService, userService)
|
||||||
listHandler := handler.NewListHandler(listService)
|
exerciseHandler := handler.NewExerciseHandler(exerciseService)
|
||||||
|
|
||||||
protected := middleware.WithJWT(authService)
|
protected := middleware.WithJWT(authService)
|
||||||
|
|
||||||
apiMux := http.NewServeMux()
|
apiMux := http.NewServeMux()
|
||||||
apiMux.HandleFunc("/", handler.DefaultHandler)
|
apiMux.HandleFunc("GET /version", handler.VersionHandler(
|
||||||
|
cfg.ServiceVersion, cfg.ServiceCommit, cfg.ServiceBuildTime))
|
||||||
apiMux.HandleFunc("GET /health", handler.HealthHandler)
|
apiMux.HandleFunc("GET /health", handler.HealthHandler)
|
||||||
|
|
||||||
|
// Auth
|
||||||
apiMux.HandleFunc("POST /login", authHandler.Login)
|
apiMux.HandleFunc("POST /login", authHandler.Login)
|
||||||
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
|
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
|
||||||
apiMux.HandleFunc("POST /logout", authHandler.Logout)
|
apiMux.HandleFunc("POST /logout", authHandler.Logout)
|
||||||
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
|
apiMux.HandleFunc("POST /logout/all", protected(authHandler.LogoutAllDevices))
|
||||||
|
|
||||||
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
|
// Users
|
||||||
|
apiMux.HandleFunc("POST /users", userHandler.CreateUser)
|
||||||
|
|
||||||
|
// User
|
||||||
|
apiMux.Handle("POST /user/password", protected(userHandler.ChangePassword))
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
apiMux.Handle("POST /user/invites", protected(inviteHandler.CreateInvite))
|
||||||
|
apiMux.Handle("DELETE /user/invites/{id}", protected(inviteHandler.DeleteInvite))
|
||||||
|
apiMux.Handle("GET /user/invites", protected(inviteHandler.GetInvites))
|
||||||
|
apiMux.Handle("GET /user/invites/status", protected(inviteHandler.GetInvitesStatus))
|
||||||
|
|
||||||
|
// Lists
|
||||||
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
|
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
|
||||||
|
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
|
||||||
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
|
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
|
||||||
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
|
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
|
||||||
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList))
|
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList))
|
||||||
apiMux.Handle("POST /lists/item", protected(listHandler.CreateListItem))
|
apiMux.Handle("POST /lists/order", protected(listHandler.OrderLists))
|
||||||
apiMux.Handle("PUT /lists/item", protected(listHandler.UpdateListItem))
|
apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem))
|
||||||
apiMux.Handle("POST /lists/items/{id}", protected(listHandler.SetListItemCompleted))
|
apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
|
||||||
|
apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
|
||||||
|
apiMux.Handle("POST /lists/items/{id}/title", protected(listHandler.SetListItemTitle))
|
||||||
|
apiMux.Handle("POST /lists/items/{id}/complete", protected(listHandler.SetListItemCompleted))
|
||||||
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
|
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
|
||||||
|
|
||||||
|
// Exercises
|
||||||
|
apiMux.Handle("GET /exercises", protected(exerciseHandler.GetExercises))
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", apiMux))
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ func assignIntFromEnv(key string, target *int) {
|
|||||||
if val, exists := os.LookupEnv(key); exists {
|
if val, exists := os.LookupEnv(key); exists {
|
||||||
parsed, err := strconv.Atoi(val)
|
parsed, err := strconv.Atoi(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to parse environment variable", slog.String("var", key), slog.Any("error", err))
|
slog.Error("failed to parse environment variable",
|
||||||
|
slog.String("key", key),
|
||||||
|
slog.String("value", val),
|
||||||
|
slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
*target = parsed
|
*target = parsed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
@@ -12,11 +12,8 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
func RunMigrations(db *sql.DB, migrationFS fs.FS) error {
|
||||||
var migrationFS embed.FS
|
sourceDriver, err := iofs.New(migrationFS, ".")
|
||||||
|
|
||||||
func RunMigrations(db *sql.DB) error {
|
|
||||||
sourceDriver, err := iofs.New(migrationFS, "migrations")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load embedded migrations: %w", err)
|
return fmt.Errorf("failed to load embedded migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS lists (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS list_users (
|
CREATE TABLE IF NOT EXISTS list_users (
|
||||||
list_id UUID REFERENCES lists(id) ON DELETE CASCADE,
|
list_id UUID REFERENCES lists(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL,
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
PRIMARY KEY (list_id, user_id)
|
PRIMARY KEY (list_id, user_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS invites;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
inviter_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
invitee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
code VARCHAR(64) NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_invites_code ON invites (code);
|
||||||
|
CREATE INDEX idx_invites_inviter_user_id ON invites (inviter_user_id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DROP TABLE IF EXISTS workout_sets;
|
||||||
|
DROP TABLE IF EXISTS workouts;
|
||||||
|
DROP TABLE IF EXISTS template_exercises;
|
||||||
|
DROP TABLE IF EXISTS templates;
|
||||||
|
DROP TABLE IF EXISTS exercises;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS exercises (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
equipment TEXT[] NOT NULL DEFAULT '{}'
|
||||||
|
CHECK (
|
||||||
|
equipment <@ ARRAY [
|
||||||
|
'floor',
|
||||||
|
'rings',
|
||||||
|
'pull_up_bar',
|
||||||
|
'parallel_bars',
|
||||||
|
'low_bar',
|
||||||
|
'parallettes',
|
||||||
|
'resistance_band'
|
||||||
|
]::TEXT[]),
|
||||||
|
metric TEXT NOT NULL DEFAULT 'reps'
|
||||||
|
CHECK (metric IN ('reps', 'seconds')),
|
||||||
|
load TEXT NOT NULL DEFAULT 'bodyweight'
|
||||||
|
CHECK (load IN ('bodyweight', 'absolute')),
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
notes TEXT,
|
||||||
|
progresses_from_id UUID REFERENCES exercises(id) ON DELETE SET NULL,
|
||||||
|
hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_exercises_name_shared_key ON exercises (lower(name)) WHERE user_id IS NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_exercises_name_personal_key ON exercises (user_id, lower(name)) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_exercises_equipment ON exercises USING gin (equipment);
|
||||||
|
CREATE INDEX idx_exercises_tags ON exercises USING gin (tags);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS template_exercises (
|
||||||
|
template_id UUID NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
|
||||||
|
exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||||
|
position SMALLINT NOT NULL,
|
||||||
|
group_no SMALLINT,
|
||||||
|
target_sets SMALLINT NOT NULL,
|
||||||
|
target_reps SMALLINT,
|
||||||
|
target_seconds SMALLINT,
|
||||||
|
target_to_failure BOOLEAN,
|
||||||
|
rest_seconds SMALLINT,
|
||||||
|
notes TEXT,
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (template_id, exercise_id),
|
||||||
|
|
||||||
|
CONSTRAINT has_target CHECK (target_reps IS NOT NULL OR target_seconds IS NOT NULL OR target_to_failure IS TRUE)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bodyweight_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
bodyweight NUMERIC(5,2) NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'kg'
|
||||||
|
CHECK (unit in ('kg', 'lbs')),
|
||||||
|
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workouts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
template_id UUID REFERENCES templates(id) ON DELETE SET NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
rpe NUMERIC(3,1) CHECK (rpe BETWEEN 1.0 AND 10.0),
|
||||||
|
notes TEXT,
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_workouts_time ON workouts (user_id, started_at DESC);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workout_sets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workout_id UUID NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||||
|
exercise_id UUID NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
|
||||||
|
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
reps SMALLINT,
|
||||||
|
seconds SMALLINT,
|
||||||
|
weight NUMERIC(5,2),
|
||||||
|
unit TEXT NOT NULL DEFAULT 'kg'
|
||||||
|
CHECK (unit in ('kg', 'lbs')),
|
||||||
|
rpe NUMERIC(3,1) CHECK (rpe BETWEEN 1.0 AND 10.0),
|
||||||
|
type TEXT NOT NULL DEFAULT 'working'
|
||||||
|
CHECK (type IN ('warm-up', 'working', 'drop')),
|
||||||
|
note TEXT,
|
||||||
|
modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
CONSTRAINT set_has_number CHECK (reps IS NOT NULL OR seconds IS NOT NULL)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sets_workout ON workout_sets (workout_id, logged_at);
|
||||||
|
CREATE INDEX idx_sets_exercise ON workout_sets (exercise_id, logged_at DESC);
|
||||||
|
|
||||||
|
INSERT INTO exercises (name, metric, load, tags, equipment) VALUES
|
||||||
|
('Push-up', 'reps', 'bodyweight', '{push,chest,triceps}', '{floor,rings,parallettes}'),
|
||||||
|
('Diamond push-up', 'reps', 'bodyweight', '{push,triceps}', '{floor,rings,parallettes}'),
|
||||||
|
('Pseudo planche push-up','reps', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'),
|
||||||
|
('Pike push-up', 'reps', 'bodyweight', '{push,shoulders}', '{floor,rings,parallettes}'),
|
||||||
|
('Dip', 'reps', 'bodyweight', '{push,chest,triceps}', '{rings,parallel_bars}'),
|
||||||
|
('Handstand hold', 'seconds', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'),
|
||||||
|
('Handstand push-up', 'reps', 'bodyweight', '{push,shoulders,skill}', '{floor,rings,parallettes}'),
|
||||||
|
('Pull-up', 'reps', 'bodyweight', '{pull,back,biceps}', '{rings,pull_up_bar}'),
|
||||||
|
('Chin-up', 'reps', 'bodyweight', '{pull,back,biceps}', '{rings,pull_up_bar}'),
|
||||||
|
('Inverted row', 'reps', 'bodyweight', '{pull,back}', '{rings,parallel_bars,low_bar}'),
|
||||||
|
('Inverted deadlift', 'reps', 'bodyweight', '{pull,back}', '{rings,parallel_bars,low_bar,pull_up_bar}'),
|
||||||
|
('Muscle-up', 'reps', 'bodyweight', '{pull,push,skill}', '{rings,pull_up_bar}'),
|
||||||
|
('Dead hang', 'seconds', 'bodyweight', '{pull,grip}', '{rings,pull_up_bar}'),
|
||||||
|
('One arm dead hang', 'seconds', 'bodyweight', '{pull,grip}', '{rings,pull_up_bar}'),
|
||||||
|
('Front lever hold', 'seconds', 'bodyweight', '{pull,core,skill}', '{rings,pull_up_bar,low_bar}'),
|
||||||
|
('Pistol squat', 'reps', 'bodyweight', '{legs}', '{floor}'),
|
||||||
|
('Bulgarian split squat','reps', 'bodyweight', '{legs}', '{floor}'),
|
||||||
|
('Nordic curl', 'reps', 'bodyweight', '{legs,hamstrings}', '{floor}'),
|
||||||
|
('Calf raise', 'reps', 'bodyweight', '{legs}', '{floor}'),
|
||||||
|
('Plank', 'seconds', 'bodyweight', '{core}', '{floor}'),
|
||||||
|
('Hollow body hold', 'seconds', 'bodyweight', '{core}', '{floor}'),
|
||||||
|
('L-sit', 'seconds', 'bodyweight', '{core,skill}', '{floor,parallettes,parallel_bars}'),
|
||||||
|
('Hanging leg raise', 'reps', 'bodyweight', '{core}', '{rings,pull_up_bar}'),
|
||||||
|
('Face pull', 'reps', 'bodyweight', '{pull,shoulders}', '{rings,resistance_band}'),
|
||||||
|
('Lateral raise', 'reps', 'absolute', '{shoulders,isolation}', '{floor}');
|
||||||
|
|
||||||
|
UPDATE exercises SET progresses_from_id = (SELECT id FROM exercises WHERE name = 'Pike push-up')
|
||||||
|
WHERE name = 'Handstand push-up';
|
||||||
|
UPDATE exercises SET progresses_from_id = (SELECT id FROM exercises WHERE name = 'Dead hang')
|
||||||
|
WHERE name = 'One arm dead hang';
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE exercises DROP CONSTRAINT exercises_load_check;
|
||||||
|
|
||||||
|
UPDATE exercises SET load = 'absolute' WHERE load = 'external';
|
||||||
|
|
||||||
|
ALTER TABLE exercises ADD CONSTRAINT exercises_load_check
|
||||||
|
CHECK (load IN ('bodyweight', 'absolute'));
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE exercises DROP CONSTRAINT exercises_load_check;
|
||||||
|
|
||||||
|
UPDATE exercises SET load = 'external' WHERE load = 'absolute';
|
||||||
|
|
||||||
|
ALTER TABLE exercises ADD CONSTRAINT exercises_load_check
|
||||||
|
CHECK (load IN ('bodyweight', 'external'));
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_list_users_position;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS list_users
|
||||||
|
DROP COLUMN IF EXISTS position;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS list_users
|
||||||
|
ADD COLUMN IF NOT EXISTS position BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_list_users_user_id_position ON list_users (user_id, position);
|
||||||
|
|
||||||
|
UPDATE list_users lu
|
||||||
|
SET position = r.rn - 1
|
||||||
|
FROM (SELECT list_id,
|
||||||
|
user_id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY user_id
|
||||||
|
ORDER BY created_at, list_id
|
||||||
|
) AS rn
|
||||||
|
FROM list_users) r
|
||||||
|
WHERE lu.list_id = r.list_id
|
||||||
|
AND lu.user_id = r.user_id;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var MigrationFS embed.FS
|
||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmailNotFound = errors.New("email not found")
|
||||||
|
ErrPasswordWrong = errors.New("password is wrong")
|
||||||
|
)
|
||||||
|
|
||||||
type AuthRepository interface {
|
type AuthRepository interface {
|
||||||
GetUserById(ctx context.Context, id string) (*AuthUser, error)
|
GetUserById(ctx context.Context, id string) (*AuthUser, error)
|
||||||
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
|
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
|
||||||
@@ -62,7 +67,7 @@ func NewAuthService(r AuthRepository, jwtSecret []byte) *AuthService {
|
|||||||
return &AuthService{repo: r, jwtSecret: jwtSecret}
|
return &AuthService{repo: r, jwtSecret: jwtSecret}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) authenticate(ctx context.Context, email string, password string) (*AuthUser, error) {
|
func (s *AuthService) Authenticate(ctx context.Context, email string, password string) (*AuthUser, error) {
|
||||||
user, err := s.repo.GetUserByEmail(ctx, email)
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
@@ -71,7 +76,7 @@ func (s *AuthService) authenticate(ctx context.Context, email string, password s
|
|||||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
return user, errors.New("invalid email or password")
|
return user, ErrPasswordWrong
|
||||||
}
|
}
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +85,7 @@ func (s *AuthService) authenticate(ctx context.Context, email string, password s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) Login(ctx context.Context, email string, password string) (TokenPair, error) {
|
func (s *AuthService) Login(ctx context.Context, email string, password string) (TokenPair, error) {
|
||||||
user, err := s.authenticate(ctx, email, password)
|
user, err := s.Authenticate(ctx, email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
return TokenPair{}, err
|
||||||
}
|
}
|
||||||
@@ -152,8 +157,8 @@ func (s *AuthService) GenerateAccessToken(authUser *AuthUser) (string, error) {
|
|||||||
UserID: authUser.ID,
|
UserID: authUser.ID,
|
||||||
Email: authUser.Email,
|
Email: authUser.Email,
|
||||||
Name: authUser.Name,
|
Name: authUser.Name,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 15)),
|
||||||
}
|
}
|
||||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Minute * 15))
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExerciseRepository interface {
|
||||||
|
CreateExercise(ctx context.Context) (*Exercise, error)
|
||||||
|
DeleteExercise(ctx context.Context, id string) error
|
||||||
|
GetExercises(ctx context.Context, offset int, count int) ([]Exercise, error)
|
||||||
|
CountExercises(ctx context.Context) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Exercise struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Equipment []Equipment `json:"equipment"`
|
||||||
|
Metric Metric `json:"metric"`
|
||||||
|
Load Load `json:"load"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
ModifiedAt time.Time `json:"modified_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExerciseService struct {
|
||||||
|
repo ExerciseRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExerciseService(r ExerciseRepository) *ExerciseService {
|
||||||
|
return &ExerciseService{repo: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExerciseService) GetExercises(ctx context.Context, page int, count int) ([]Exercise, error) {
|
||||||
|
offset := (page - 1) * count
|
||||||
|
return s.repo.GetExercises(ctx, offset, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExerciseService) CountExercises(ctx context.Context) (int, error) {
|
||||||
|
return s.repo.CountExercises(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Equipment int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EquipmentUnknown Equipment = iota
|
||||||
|
EquipmentFloor
|
||||||
|
EquipmentRings
|
||||||
|
EquipmentPullUpBar
|
||||||
|
EquipmentParallelBars
|
||||||
|
EquipmentLowBar
|
||||||
|
EquipmentParallettes
|
||||||
|
EquipmentResistanceBand
|
||||||
|
)
|
||||||
|
|
||||||
|
var equipmentNames = [...]string{
|
||||||
|
EquipmentUnknown: "",
|
||||||
|
EquipmentFloor: "floor",
|
||||||
|
EquipmentRings: "rings",
|
||||||
|
EquipmentPullUpBar: "pull_up_bar",
|
||||||
|
EquipmentParallelBars: "parallel_bars",
|
||||||
|
EquipmentLowBar: "low_bar",
|
||||||
|
EquipmentParallettes: "parallettes",
|
||||||
|
EquipmentResistanceBand: "resistance_band",
|
||||||
|
}
|
||||||
|
|
||||||
|
var equipmentValues = func() map[string]Equipment {
|
||||||
|
m := make(map[string]Equipment, len(equipmentNames))
|
||||||
|
for i, name := range equipmentNames {
|
||||||
|
m[name] = Equipment(i)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
func (e Equipment) String() string {
|
||||||
|
if e < 0 || int(e) > len(equipmentNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return equipmentNames[e]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseEquipment(s string) (Equipment, error) {
|
||||||
|
if e, ok := equipmentValues[s]; ok {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
return EquipmentUnknown, fmt.Errorf("equipment: unknown value %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
//func (e Equipment) MarshalJSON() ([]byte, error) {
|
||||||
|
// s := e.String()
|
||||||
|
// if s == "" {
|
||||||
|
// return nil, fmt.Errorf("equipment: cannot marshal value %d", int(e))
|
||||||
|
// }
|
||||||
|
// return json.Marshal(s)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//func (e *Equipment) UnmarshalJSON(data []byte) error {
|
||||||
|
// var s string
|
||||||
|
// if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// v, err := ParseEquipment(s)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// *e = v
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
|
||||||
|
type EquipmentSet []Equipment
|
||||||
|
|
||||||
|
func (s *EquipmentSet) Scan(src any) error {
|
||||||
|
if src == nil {
|
||||||
|
*s = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case string:
|
||||||
|
raw = v
|
||||||
|
case []byte:
|
||||||
|
raw = string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("equipment: cannot scan %T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := parseTextArray(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := make(EquipmentSet, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
e, err := ParseEquipment(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out[i] = e
|
||||||
|
}
|
||||||
|
*s = out
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s EquipmentSet) Value() (driver.Value, error) {
|
||||||
|
names := make([]string, len(s))
|
||||||
|
for i, e := range s {
|
||||||
|
name := e.String()
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("equipment: cannot store value %d", int(e))
|
||||||
|
}
|
||||||
|
names[i] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{" + strings.Join(names, ",") + "}", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTextArray(raw string) ([]string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if len(raw) < 2 || raw[0] != '{' || raw[len(raw)-1] != '}' {
|
||||||
|
return nil, fmt.Errorf("equipment: malformed array %q", raw)
|
||||||
|
}
|
||||||
|
if inner := raw[1 : len(raw)-1]; inner != "" {
|
||||||
|
return strings.Split(inner, ","), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Load int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoadUnknown Load = iota
|
||||||
|
LoadBodyweight
|
||||||
|
LoadExternal
|
||||||
|
)
|
||||||
|
|
||||||
|
var loadNames = [...]string{
|
||||||
|
LoadUnknown: "",
|
||||||
|
LoadBodyweight: "bodyweight",
|
||||||
|
LoadExternal: "external",
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadValues = func() map[string]Load {
|
||||||
|
m := make(map[string]Load, len(loadNames))
|
||||||
|
for i, name := range loadNames {
|
||||||
|
m[name] = Load(i)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
func (l Load) String() string {
|
||||||
|
if l < 0 || int(l) > len(loadNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return loadNames[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLoad(s string) (Load, error) {
|
||||||
|
if l, ok := loadValues[s]; ok {
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
return LoadUnknown, fmt.Errorf("load: unknown value %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
//func (l Load) MarshalJSON() ([]byte, error) {
|
||||||
|
// s := l.String()
|
||||||
|
// if s == "" {
|
||||||
|
// return nil, fmt.Errorf("load: cannot marshal value %d", int(l))
|
||||||
|
// }
|
||||||
|
// return json.Marshal(s)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//func (l *Load) UnmarshalJSON(data []byte) error {
|
||||||
|
// var s string
|
||||||
|
// if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// v, err := ParseLoad(s)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// *l = v
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
|
||||||
|
func (l *Load) Scan(src any) error {
|
||||||
|
var s string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
return fmt.Errorf("load: unexpected NULL")
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("load: cannot scan %T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseLoad(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*l = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Load) Value() (driver.Value, error) {
|
||||||
|
s := l.String()
|
||||||
|
if s == "" {
|
||||||
|
return nil, fmt.Errorf("load: cannot store value %d", int(l))
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metric int
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetricUnknown Metric = iota
|
||||||
|
MetricReps
|
||||||
|
MetricSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
var metricNames = [...]string{
|
||||||
|
MetricUnknown: "",
|
||||||
|
MetricReps: "reps",
|
||||||
|
MetricSeconds: "seconds",
|
||||||
|
}
|
||||||
|
|
||||||
|
var metricValues = func() map[string]Metric {
|
||||||
|
m := make(map[string]Metric, len(metricNames))
|
||||||
|
for i, name := range metricNames {
|
||||||
|
m[name] = Metric(i)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
func (m Metric) String() string {
|
||||||
|
if m < 0 || int(m) > len(metricNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return metricNames[m]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMetric(s string) (Metric, error) {
|
||||||
|
if m, ok := metricValues[s]; ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return MetricUnknown, fmt.Errorf("metric: unknown value %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
//func (m Metric) MarshalJSON() ([]byte, error) {
|
||||||
|
// s := m.String()
|
||||||
|
// if s == "" {
|
||||||
|
// return nil, fmt.Errorf("metric: cannot marshal value %d", int(m))
|
||||||
|
// }
|
||||||
|
// return json.Marshal(s)
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//func (m *Metric) UnmarshalJSON(data []byte) error {
|
||||||
|
// var s string
|
||||||
|
// if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// v, err := ParseMetric(s)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// *m = v
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
|
||||||
|
func (m *Metric) Scan(src any) error {
|
||||||
|
var s string
|
||||||
|
switch v := src.(type) {
|
||||||
|
case nil:
|
||||||
|
return fmt.Errorf("metric: unexpected NULL")
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("metric: cannot scan %T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseMetric(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*m = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Metric) Value() (driver.Value, error) {
|
||||||
|
s := m.String()
|
||||||
|
if s == "" {
|
||||||
|
return nil, fmt.Errorf("metric: cannot store value %d", int(m))
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInviteIDMissing = errors.New("invite id is required")
|
||||||
|
ErrCodeMissing = errors.New("invite code is required")
|
||||||
|
ErrInviteInvalid = errors.New("invite is invalid")
|
||||||
|
ErrInviteExpired = errors.New("invite is expired")
|
||||||
|
ErrInviteConsumed = errors.New("invite is already consumed")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
ConsumedAt *time.Time `json:"consumed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteCounts struct {
|
||||||
|
Active int `json:"active"`
|
||||||
|
Expired int `json:"expired"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteRepository interface {
|
||||||
|
CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*Invite, error)
|
||||||
|
DeleteInvite(ctx context.Context, userID string, inviteID string) error
|
||||||
|
ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error
|
||||||
|
GetInvite(ctx context.Context, code string) (*Invite, error)
|
||||||
|
GetInvites(ctx context.Context, userID string, offset int, count int) ([]Invite, error)
|
||||||
|
CountInvites(ctx context.Context, userID string) (int, error)
|
||||||
|
CountInvitesStructured(ctx context.Context, userID string) (*InviteCounts, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteService struct {
|
||||||
|
repo InviteRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInviteService(r InviteRepository) *InviteService {
|
||||||
|
return &InviteService{repo: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) CreateInvite(ctx context.Context, inviterUserID string) (*Invite, error) {
|
||||||
|
if inviterUserID == "" {
|
||||||
|
return nil, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateSecureToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code := hashToken(token)
|
||||||
|
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
||||||
|
|
||||||
|
return s.repo.CreateInvite(ctx, inviterUserID, code, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
|
||||||
|
if userID == "" {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
if inviteID == "" {
|
||||||
|
return ErrInviteIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteInvite(ctx, userID, inviteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
|
||||||
|
if inviteeUserID == "" {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.ConsumeInvite(ctx, inviteID, inviteeUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, ErrCodeMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
invite, err := s.repo.GetInvite(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if invite.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, ErrInviteExpired
|
||||||
|
}
|
||||||
|
if invite.ConsumedAt != nil {
|
||||||
|
return nil, ErrInviteConsumed
|
||||||
|
}
|
||||||
|
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) GetInvites(ctx context.Context, userID string, page int, countPerPage int) ([]Invite, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * countPerPage
|
||||||
|
return s.repo.GetInvites(ctx, userID, offset, countPerPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) CountInvites(ctx context.Context, userID string) (int, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return 0, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.CountInvites(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) CountInvitesStructured(ctx context.Context, userID string) (*InviteCounts, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.CountInvitesStructured(ctx, userID)
|
||||||
|
}
|
||||||
+143
-29
@@ -4,17 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrListIDEmpty = errors.New("list id must not be empty")
|
ErrListIDMissing = errors.New("list id is required")
|
||||||
ErrListNameEmpty = errors.New("list name must not be empty")
|
ErrListNameMissing = errors.New("list name is required")
|
||||||
ErrUserIDEmpty = errors.New("user id must not be empty")
|
ErrListItemIDMissing = errors.New("list item id is required")
|
||||||
ErrListItemIDEmpty = errors.New("list item id must not be empty")
|
ErrListItemTitleMissing = errors.New("list item title is required")
|
||||||
ErrListItemTitleEmpty = errors.New("list item title must not be empty")
|
|
||||||
ErrUserNotInList = errors.New("user not in list")
|
ErrUserNotInList = errors.New("user not in list")
|
||||||
|
ErrStaleListIDs = errors.New("list ids out of date")
|
||||||
)
|
)
|
||||||
|
|
||||||
type List struct {
|
type List struct {
|
||||||
@@ -22,6 +22,9 @@ type List struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ModifiedAt time.Time `json:"modified_at"`
|
ModifiedAt time.Time `json:"modified_at"`
|
||||||
|
TotalItems int `json:"total_items"`
|
||||||
|
CompletedItems int `json:"completed_items"`
|
||||||
|
Position int `json:"position"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListItem struct {
|
type ListItem struct {
|
||||||
@@ -34,36 +37,69 @@ type ListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListRepository interface {
|
type ListRepository interface {
|
||||||
CreateList(ctx context.Context, name string, userIDs []string) (*List, error)
|
CreateList(ctx context.Context, name string) (*List, error)
|
||||||
|
DeleteList(ctx context.Context, listID string) error
|
||||||
GetLists(ctx context.Context, userID string) ([]List, error)
|
GetLists(ctx context.Context, userID string) ([]List, error)
|
||||||
AddUserToList(ctx context.Context, listID string, userID string) error
|
AddUserToList(ctx context.Context, listID string, userID string) error
|
||||||
RemoveUserFromList(ctx context.Context, listID string, userID string) error
|
RemoveUserFromList(ctx context.Context, listID string, userID string) error
|
||||||
|
OrderLists(ctx context.Context, userID string, listIDs []string) error
|
||||||
|
LockUsersLists(ctx context.Context, userID string) ([]string, error)
|
||||||
IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
|
IsUserInList(ctx context.Context, listID string, userID string) (bool, error)
|
||||||
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
|
IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error)
|
||||||
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
|
CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error)
|
||||||
|
DeleteListItem(ctx context.Context, listItemID string) error
|
||||||
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
|
UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error
|
||||||
|
SetListItemTitle(ctx context.Context, listItemID string, title string) error
|
||||||
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
|
SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error
|
||||||
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
|
GetListItems(ctx context.Context, listID string) ([]ListItem, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListService struct {
|
type ListService struct {
|
||||||
|
tx Transactor
|
||||||
repo ListRepository
|
repo ListRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListService(r ListRepository) *ListService {
|
func NewListService(tx Transactor, r ListRepository) *ListService {
|
||||||
return &ListService{repo: r}
|
return &ListService{tx: tx, repo: r}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ListService) Create(ctx context.Context, authUserID string, name string, userIDs []string) (*List, error) {
|
func (s *ListService) CreateList(ctx context.Context, authUserID string, name string) (*List, error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, ErrListNameEmpty
|
return nil, ErrListNameMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(userIDs, authUserID) {
|
var list *List
|
||||||
userIDs = append(userIDs, authUserID)
|
err := s.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
l, err := s.repo.CreateList(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.repo.CreateList(ctx, name, userIDs)
|
err = s.repo.AddUserToList(ctx, l.ID, authUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
list = l
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ListService) DeleteList(ctx context.Context, authUserID string, listID string) error {
|
||||||
|
if listID == "" {
|
||||||
|
return ErrListIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteList(ctx, listID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List, error) {
|
func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List, error) {
|
||||||
@@ -72,29 +108,25 @@ func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List,
|
|||||||
|
|
||||||
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
|
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
|
||||||
if listID == "" {
|
if listID == "" {
|
||||||
return ErrListIDEmpty
|
return ErrListIDMissing
|
||||||
}
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
return ErrUserIDEmpty
|
return ErrUserIDMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
inList, err := s.repo.IsUserInList(ctx, listID, authUserID)
|
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !inList {
|
|
||||||
return ErrUserNotInList
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.repo.AddUserToList(ctx, listID, userID)
|
return s.repo.AddUserToList(ctx, listID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
|
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
|
||||||
if listID == "" {
|
if listID == "" {
|
||||||
return ErrListIDEmpty
|
return ErrListIDMissing
|
||||||
}
|
}
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
return ErrUserIDEmpty
|
return ErrUserIDMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
||||||
@@ -104,12 +136,42 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
|
|||||||
return s.repo.RemoveUserFromList(ctx, listID, userID)
|
return s.repo.RemoveUserFromList(ctx, listID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ListService) OrderLists(ctx context.Context, authUserID string, listIDs []string) error {
|
||||||
|
if authUserID == "" {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
if len(listIDs) == 0 {
|
||||||
|
return ErrListIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
serverIDs, err := s.repo.LockUsersLists(ctx, authUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPermutation(listIDs, serverIDs) {
|
||||||
|
slog.ErrorContext(ctx, "no permutation",
|
||||||
|
slog.Any("client_list_ids", listIDs),
|
||||||
|
slog.Any("server_list_ids", serverIDs))
|
||||||
|
return ErrStaleListIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.repo.OrderLists(ctx, authUserID, listIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
|
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
|
||||||
if listID == "" {
|
if listID == "" {
|
||||||
return nil, ErrListIDEmpty
|
return nil, ErrListIDMissing
|
||||||
}
|
}
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return nil, ErrListItemTitleEmpty
|
return nil, ErrListItemTitleMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
||||||
@@ -119,12 +181,24 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis
|
|||||||
return s.repo.CreateListItem(ctx, listID, title)
|
return s.repo.CreateListItem(ctx, listID, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, listItemID string) error {
|
||||||
|
if listItemID == "" {
|
||||||
|
return ErrListItemIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteListItem(ctx, listItemID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
|
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
|
||||||
if listItemID == "" {
|
if listItemID == "" {
|
||||||
return ErrListItemIDEmpty
|
return ErrListItemIDMissing
|
||||||
}
|
}
|
||||||
if title == "" {
|
if title == "" {
|
||||||
return ErrListItemTitleEmpty
|
return ErrListItemTitleMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
||||||
@@ -134,9 +208,24 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis
|
|||||||
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
|
return s.repo.UpdateListItem(ctx, listItemID, title, isCompleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ListService) SetListItemTitle(ctx context.Context, authUserID string, listItemID string, title string) error {
|
||||||
|
if listItemID == "" {
|
||||||
|
return ErrListItemIDMissing
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return ErrListItemTitleMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.SetListItemTitle(ctx, listItemID, title)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
|
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
|
||||||
if listItemID == "" {
|
if listItemID == "" {
|
||||||
return ErrListItemIDEmpty
|
return ErrListItemIDMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
|
||||||
@@ -148,7 +237,7 @@ func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID strin
|
|||||||
|
|
||||||
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
|
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
|
||||||
if listID == "" {
|
if listID == "" {
|
||||||
return nil, ErrListIDEmpty
|
return nil, ErrListIDMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
|
||||||
@@ -203,3 +292,28 @@ func (s *ListService) userAllowedToAccessListItem(ctx context.Context, authUserI
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPermutation(a []string, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
aMap := make(map[string]struct{}, len(a))
|
||||||
|
for _, v := range a {
|
||||||
|
aMap[strings.ToLower(v)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(a))
|
||||||
|
for _, v := range b {
|
||||||
|
id := strings.ToLower(v)
|
||||||
|
if _, ok := aMap[id]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, dup := seen[id]; dup {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
+766
-47
@@ -1,4 +1,4 @@
|
|||||||
package domain_test
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,79 +6,798 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockListRepo struct {
|
var (
|
||||||
|
_ Transactor = (*fakeTransactor)(nil)
|
||||||
|
_ ListRepository = (*mockListRepository)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
type txCtxKey struct{}
|
||||||
|
type fakeTransactor struct {
|
||||||
|
calls int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTransactor) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
return fn(context.WithValue(ctx, txCtxKey{}, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
var inTx = mock.MatchedBy(func(ctx context.Context) bool {
|
||||||
|
v, _ := ctx.Value(txCtxKey{}).(bool)
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
|
||||||
|
type mockListRepository struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) {
|
func (m *mockListRepository) CreateList(ctx context.Context, name string) (*List, error) {
|
||||||
args := m.Called(ctx, name, userIDs)
|
args := m.Called(ctx, name)
|
||||||
var list *domain.List
|
list, _ := args.Get(0).(*List)
|
||||||
if l := args.Get(0); l != nil {
|
|
||||||
list = l.(*domain.List)
|
|
||||||
}
|
|
||||||
return list, args.Error(1)
|
return list, args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListService_Create_Success(t *testing.T) {
|
func (m *mockListRepository) DeleteList(ctx context.Context, listID string) error {
|
||||||
expectedList := &domain.List{
|
args := m.Called(ctx, listID)
|
||||||
ID: "1",
|
return args.Error(0)
|
||||||
Name: "My List",
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
ModifiedAt: time.Now(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := new(mockListRepo)
|
func (m *mockListRepository) GetLists(ctx context.Context, userID string) ([]List, error) {
|
||||||
repo.On("CreateList", mock.Anything, "My List", []string{"user1", "user2"}).Return(expectedList, nil)
|
args := m.Called(ctx, userID)
|
||||||
|
lists, _ := args.Get(0).([]List)
|
||||||
|
return lists, args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
service := domain.NewListService(repo)
|
func (m *mockListRepository) AddUserToList(ctx context.Context, listID string, userID string) error {
|
||||||
list, err := service.Create(context.Background(), "My List", []string{"user1", "user2"})
|
args := m.Called(ctx, listID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
|
||||||
|
args := m.Called(ctx, listID, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) OrderLists(ctx context.Context, userID string, listIDs []string) error {
|
||||||
|
args := m.Called(ctx, userID, listIDs)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) LockUsersLists(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
args := m.Called(ctx, userID)
|
||||||
|
ids, _ := args.Get(0).([]string)
|
||||||
|
return ids, args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
|
||||||
|
args := m.Called(ctx, listID, userID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
|
||||||
|
args := m.Called(ctx, listItemID, userID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) CreateListItem(ctx context.Context, listID string, title string) (*ListItem, error) {
|
||||||
|
args := m.Called(ctx, listID, title)
|
||||||
|
item, _ := args.Get(0).(*ListItem)
|
||||||
|
return item, args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) DeleteListItem(ctx context.Context, listItemID string) error {
|
||||||
|
args := m.Called(ctx, listItemID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
|
||||||
|
args := m.Called(ctx, listItemID, title, isCompleted)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) SetListItemTitle(ctx context.Context, listItemID string, title string) error {
|
||||||
|
args := m.Called(ctx, listItemID, title)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
|
||||||
|
args := m.Called(ctx, listItemID, isCompleted)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockListRepository) GetListItems(ctx context.Context, listID string) ([]ListItem, error) {
|
||||||
|
args := m.Called(ctx, listID)
|
||||||
|
items, _ := args.Get(0).([]ListItem)
|
||||||
|
return items, args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListService(t *testing.T) (*ListService, *mockListRepository, *fakeTransactor) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
repo := &mockListRepository{}
|
||||||
|
repo.Test(t)
|
||||||
|
t.Cleanup(func() { repo.AssertExpectations(t) })
|
||||||
|
|
||||||
|
tx := &fakeTransactor{}
|
||||||
|
|
||||||
|
return NewListService(tx, repo), repo, tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCallOrder(t *testing.T, repo *mockListRepository, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for _, c := range repo.Calls {
|
||||||
|
got = append(got, c.Method)
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_CreateList(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("creates the list and adds the creator in one transaction", func(t *testing.T) {
|
||||||
|
svc, repo, tx := newListService(t)
|
||||||
|
|
||||||
|
created := &List{ID: "list-1", Name: "Groceries", CreatedAt: time.Now()}
|
||||||
|
repo.On("CreateList", inTx, "Groceries").Return(created, nil)
|
||||||
|
repo.On("AddUserToList", inTx, "list-1", "user-1").Return(nil)
|
||||||
|
|
||||||
|
list, err := svc.CreateList(ctx, "user-1", "Groceries")
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedList, list)
|
assert.Equal(t, created, list)
|
||||||
repo.AssertExpectations(t)
|
assert.Equal(t, 1, tx.calls)
|
||||||
}
|
assertCallOrder(t, repo, "CreateList", "AddUserToList")
|
||||||
|
})
|
||||||
|
|
||||||
func TestListService_Create_EmptyName(t *testing.T) {
|
t.Run("insert error aborts before adding the user", func(t *testing.T) {
|
||||||
repo := new(mockListRepo)
|
svc, repo, _ := newListService(t)
|
||||||
service := domain.NewListService(repo)
|
|
||||||
|
|
||||||
list, err := service.Create(context.Background(), "", []string{"user1"})
|
repoErr := errors.New("insert failed")
|
||||||
|
repo.On("CreateList", inTx, "Groceries").Return(nil, repoErr)
|
||||||
|
|
||||||
|
list, err := svc.CreateList(ctx, "user-1", "Groceries")
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.EqualError(t, err, "list name must not be empty")
|
|
||||||
assert.Nil(t, list)
|
assert.Nil(t, list)
|
||||||
repo.AssertExpectations(t)
|
assert.ErrorIs(t, err, repoErr)
|
||||||
}
|
assertCallOrder(t, repo, "CreateList")
|
||||||
|
})
|
||||||
|
|
||||||
func TestListService_Create_EmptyUsers(t *testing.T) {
|
t.Run("membership insert error fails the whole operation", func(t *testing.T) {
|
||||||
repo := new(mockListRepo)
|
svc, repo, _ := newListService(t)
|
||||||
service := domain.NewListService(repo)
|
|
||||||
|
|
||||||
list, err := service.Create(context.Background(), "My List", []string{})
|
repoErr := errors.New("foreign key violation")
|
||||||
|
repo.On("CreateList", inTx, "Groceries").Return(&List{ID: "list-1", Name: "Groceries"}, nil)
|
||||||
|
repo.On("AddUserToList", inTx, "list-1", "user-1").Return(repoErr)
|
||||||
|
|
||||||
|
list, err := svc.CreateList(ctx, "user-1", "Groceries")
|
||||||
|
|
||||||
|
assert.Nil(t, list, "no half-created list may be returned")
|
||||||
|
assert.ErrorIs(t, err, repoErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("transaction begin error is returned", func(t *testing.T) {
|
||||||
|
svc, repo, tx := newListService(t)
|
||||||
|
|
||||||
|
tx.err = errors.New("could not begin transaction")
|
||||||
|
|
||||||
|
list, err := svc.CreateList(ctx, "user-1", "Groceries")
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.EqualError(t, err, "users must have at least one associated user")
|
|
||||||
assert.Nil(t, list)
|
assert.Nil(t, list)
|
||||||
repo.AssertExpectations(t)
|
assert.ErrorIs(t, err, tx.err)
|
||||||
|
assertCallOrder(t, repo)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListService_Create_RepoError(t *testing.T) {
|
func TestListService_DeleteList(t *testing.T) {
|
||||||
expectedErr := errors.New("database error")
|
svc, repo, _ := newListService(t)
|
||||||
repo := new(mockListRepo)
|
|
||||||
repo.On("CreateList", mock.Anything, "My List", []string{"user1"}).Return(nil, expectedErr)
|
|
||||||
|
|
||||||
service := domain.NewListService(repo)
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("DeleteList", mock.Anything, "list-1").Return(nil)
|
||||||
|
|
||||||
list, err := service.Create(context.Background(), "My List", []string{"user1"})
|
err := svc.DeleteList(context.Background(), "user-1", "list-1")
|
||||||
|
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
assert.ErrorIs(t, err, expectedErr)
|
assertCallOrder(t, repo, "IsUserInList", "DeleteList")
|
||||||
assert.Nil(t, list)
|
}
|
||||||
repo.AssertExpectations(t)
|
|
||||||
|
func TestListService_GetLists(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("returns the user's lists", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
want := []List{{ID: "list-1", Name: "Groceries"}, {ID: "list-2", Name: "Reading"}}
|
||||||
|
repo.On("GetLists", mock.Anything, "user-1").Return(want, nil)
|
||||||
|
|
||||||
|
lists, err := svc.GetLists(ctx, "user-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, lists)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user without lists gets an empty result", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("GetLists", mock.Anything, "user-1").Return([]List{}, nil)
|
||||||
|
|
||||||
|
lists, err := svc.GetLists(ctx, "user-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, lists)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("repository error is propagated", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repoErr := errors.New("connection reset")
|
||||||
|
repo.On("GetLists", mock.Anything, "user-1").Return(nil, repoErr)
|
||||||
|
|
||||||
|
lists, err := svc.GetLists(ctx, "user-1")
|
||||||
|
|
||||||
|
assert.Nil(t, lists)
|
||||||
|
assert.ErrorIs(t, err, repoErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_AddUserToList(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(nil)
|
||||||
|
|
||||||
|
err := svc.AddUserToList(context.Background(), "user-1", "list-1", "user-2")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInList", "AddUserToList")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_RemoveUserFromList(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("member removes another member", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(nil)
|
||||||
|
|
||||||
|
err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-2")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInList", "RemoveUserFromList")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("member leaves the list", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-1").Return(nil)
|
||||||
|
|
||||||
|
err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_OrderLists(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("stores the client's order inside one transaction", func(t *testing.T) {
|
||||||
|
svc, repo, tx := newListService(t)
|
||||||
|
|
||||||
|
repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a", "list-b", "list-c"}, nil)
|
||||||
|
repo.On("OrderLists", inTx, "user-1", []string{"list-c", "list-a", "list-b"}).Return(nil)
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", []string{"list-c", "list-a", "list-b"})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, tx.calls)
|
||||||
|
assertCallOrder(t, repo, "LockUsersLists", "OrderLists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ids are compared case-insensitively", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
server := []string{
|
||||||
|
"3f2a8c1e-7d4b-4e21-9a6f-2b1c0d9e8f7a",
|
||||||
|
"9b1d4e6f-0a2c-4b3d-8e5f-6a7b8c9d0e1f",
|
||||||
|
}
|
||||||
|
client := []string{
|
||||||
|
"9B1D4E6F-0A2C-4B3D-8E5F-6A7B8C9D0E1F",
|
||||||
|
"3F2A8C1E-7D4B-4E21-9A6F-2B1C0D9E8F7A",
|
||||||
|
}
|
||||||
|
repo.On("LockUsersLists", inTx, "user-1").Return(server, nil)
|
||||||
|
// The client's spelling is passed on unchanged; Postgres' uuid cast
|
||||||
|
// doesn't care about case.
|
||||||
|
repo.On("OrderLists", inTx, "user-1", client).Return(nil)
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", client)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stale ids are rejected without writing", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
server []string
|
||||||
|
client []string
|
||||||
|
}{
|
||||||
|
{name: "client is missing a list", server: []string{"a", "b", "c"}, client: []string{"a", "b"}},
|
||||||
|
{name: "client has an extra list", server: []string{"a", "b"}, client: []string{"a", "b", "c"}},
|
||||||
|
{name: "client has an unknown list", server: []string{"a", "b"}, client: []string{"a", "x"}},
|
||||||
|
{name: "client repeats a list", server: []string{"a", "b"}, client: []string{"a", "a"}},
|
||||||
|
{name: "user has no lists anymore", server: []string{}, client: []string{"a"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("LockUsersLists", inTx, "user-1").Return(tt.server, nil)
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", tt.client)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, ErrStaleListIDs)
|
||||||
|
assertCallOrder(t, repo, "LockUsersLists")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lock error is propagated", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
lockErr := errors.New("lock timeout")
|
||||||
|
repo.On("LockUsersLists", inTx, "user-1").Return(nil, lockErr)
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, lockErr)
|
||||||
|
assert.NotErrorIs(t, err, ErrStaleListIDs)
|
||||||
|
assertCallOrder(t, repo, "LockUsersLists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update error is propagated", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
updateErr := errors.New("update failed")
|
||||||
|
repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a"}, nil)
|
||||||
|
repo.On("OrderLists", inTx, "user-1", []string{"list-a"}).Return(updateErr)
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, updateErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("transaction begin error is returned", func(t *testing.T) {
|
||||||
|
svc, repo, tx := newListService(t)
|
||||||
|
|
||||||
|
tx.err = errors.New("could not begin transaction")
|
||||||
|
|
||||||
|
err := svc.OrderLists(ctx, "user-1", []string{"list-a"})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, tx.err)
|
||||||
|
assertCallOrder(t, repo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_CreateListItem(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
created := &ListItem{ID: "item-1", ListID: "list-1", Title: "Milk", CreatedAt: time.Now()}
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(created, nil)
|
||||||
|
|
||||||
|
item, err := svc.CreateListItem(context.Background(), "user-1", "list-1", "Milk")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created, item)
|
||||||
|
assertCallOrder(t, repo, "IsUserInList", "CreateListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_DeleteListItem(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("DeleteListItem", mock.Anything, "item-1").Return(nil)
|
||||||
|
|
||||||
|
err := svc.DeleteListItem(context.Background(), "user-1", "item-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInListByItemID", "DeleteListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_UpdateListItem(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(nil)
|
||||||
|
|
||||||
|
err := svc.UpdateListItem(context.Background(), "user-1", "item-1", "Oat milk", true)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInListByItemID", "UpdateListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_SetListItemTitle(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(nil)
|
||||||
|
|
||||||
|
err := svc.SetListItemTitle(context.Background(), "user-1", "item-1", "Oat milk")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemTitle")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_SetListItemCompleted(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
completed bool
|
||||||
|
}{
|
||||||
|
{name: "marks the item as completed", completed: true},
|
||||||
|
{name: "marks the item as open again", completed: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("SetListItemCompleted", mock.Anything, "item-1", tt.completed).Return(nil)
|
||||||
|
|
||||||
|
err := svc.SetListItemCompleted(context.Background(), "user-1", "item-1", tt.completed)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemCompleted")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_GetListItems(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
want := []ListItem{
|
||||||
|
{ID: "item-1", ListID: "list-1", Title: "Milk"},
|
||||||
|
{ID: "item-2", ListID: "list-1", Title: "Bread", IsCompleted: true},
|
||||||
|
}
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil)
|
||||||
|
repo.On("GetListItems", mock.Anything, "list-1").Return(want, nil)
|
||||||
|
|
||||||
|
items, err := svc.GetListItems(context.Background(), "user-1", "list-1")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, want, items)
|
||||||
|
assertCallOrder(t, repo, "IsUserInList", "GetListItems")
|
||||||
|
}
|
||||||
|
|
||||||
|
type guardedOp struct {
|
||||||
|
name string
|
||||||
|
// byItem is true when membership is resolved through a list item id
|
||||||
|
// (IsUserInListByItemID) instead of a list id (IsUserInList).
|
||||||
|
byItem bool
|
||||||
|
// expectRepo registers the delegated repository call, returning err.
|
||||||
|
expectRepo func(repo *mockListRepository, err error)
|
||||||
|
// call invokes the service method on behalf of userID.
|
||||||
|
call func(svc *ListService, userID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (op guardedOp) guardMethod() string {
|
||||||
|
if op.byItem {
|
||||||
|
return "IsUserInListByItemID"
|
||||||
|
}
|
||||||
|
return "IsUserInList"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (op guardedOp) expectGuard(repo *mockListRepository, userID string, inList bool, err error) {
|
||||||
|
if op.byItem {
|
||||||
|
repo.On("IsUserInListByItemID", mock.Anything, "item-1", userID).Return(inList, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo.On("IsUserInList", mock.Anything, "list-1", userID).Return(inList, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardedOps() []guardedOp {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
return []guardedOp{
|
||||||
|
{
|
||||||
|
name: "DeleteList",
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("DeleteList", mock.Anything, "list-1").Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.DeleteList(ctx, userID, "list-1")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AddUserToList",
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.AddUserToList(ctx, userID, "list-1", "user-2")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoveUserFromList",
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.RemoveUserFromList(ctx, userID, "list-1", "user-2")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateListItem",
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(nil, err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
item, err := svc.CreateListItem(ctx, userID, "list-1", "Milk")
|
||||||
|
if item != nil {
|
||||||
|
return errors.New("expected no item on failure")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetListItems",
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("GetListItems", mock.Anything, "list-1").Return(nil, err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
items, err := svc.GetListItems(ctx, userID, "list-1")
|
||||||
|
if items != nil {
|
||||||
|
return errors.New("expected no items on failure")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeleteListItem",
|
||||||
|
byItem: true,
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("DeleteListItem", mock.Anything, "item-1").Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.DeleteListItem(ctx, userID, "item-1")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateListItem",
|
||||||
|
byItem: true,
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.UpdateListItem(ctx, userID, "item-1", "Oat milk", true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetListItemTitle",
|
||||||
|
byItem: true,
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.SetListItemTitle(ctx, userID, "item-1", "Oat milk")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetListItemCompleted",
|
||||||
|
byItem: true,
|
||||||
|
expectRepo: func(repo *mockListRepository, err error) {
|
||||||
|
repo.On("SetListItemCompleted", mock.Anything, "item-1", true).Return(err)
|
||||||
|
},
|
||||||
|
call: func(svc *ListService, userID string) error {
|
||||||
|
return svc.SetListItemCompleted(ctx, userID, "item-1", true)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_AccessControl(t *testing.T) {
|
||||||
|
for _, op := range guardedOps() {
|
||||||
|
t.Run(op.name, func(t *testing.T) {
|
||||||
|
t.Run("non-member is refused before any write", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
op.expectGuard(repo, "intruder", false, nil)
|
||||||
|
|
||||||
|
err := op.call(svc, "intruder")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, ErrUserNotInList)
|
||||||
|
assertCallOrder(t, repo, op.guardMethod())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("membership check error is propagated, not masked", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repoErr := errors.New("connection reset")
|
||||||
|
op.expectGuard(repo, "user-1", false, repoErr)
|
||||||
|
|
||||||
|
err := op.call(svc, "user-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, repoErr)
|
||||||
|
assert.NotErrorIs(t, err, ErrUserNotInList)
|
||||||
|
assertCallOrder(t, repo, op.guardMethod())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("repository error is propagated", func(t *testing.T) {
|
||||||
|
svc, repo, _ := newListService(t)
|
||||||
|
|
||||||
|
repoErr := errors.New("write failed")
|
||||||
|
op.expectGuard(repo, "user-1", true, nil)
|
||||||
|
op.expectRepo(repo, repoErr)
|
||||||
|
|
||||||
|
err := op.call(svc, "user-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, repoErr)
|
||||||
|
assertCallOrder(t, repo, op.guardMethod(), op.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListService_ValidationErrors(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
call func(svc *ListService) error
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CreateList without name",
|
||||||
|
call: func(svc *ListService) error {
|
||||||
|
_, err := svc.CreateList(ctx, "user-1", "")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: ErrListNameMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeleteList without list id",
|
||||||
|
call: func(svc *ListService) error { return svc.DeleteList(ctx, "user-1", "") },
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AddUserToList without list id",
|
||||||
|
call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "", "user-2") },
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AddUserToList without user id",
|
||||||
|
call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "list-1", "") },
|
||||||
|
wantErr: ErrUserIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoveUserFromList without list id",
|
||||||
|
call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "", "user-2") },
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemoveUserFromList without user id",
|
||||||
|
call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "list-1", "") },
|
||||||
|
wantErr: ErrUserIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OrderLists without user id",
|
||||||
|
call: func(svc *ListService) error { return svc.OrderLists(ctx, "", []string{"list-1"}) },
|
||||||
|
wantErr: ErrUserIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OrderLists with nil list ids",
|
||||||
|
call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", nil) },
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OrderLists with empty list ids",
|
||||||
|
call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", []string{}) },
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateListItem without list id",
|
||||||
|
call: func(svc *ListService) error {
|
||||||
|
_, err := svc.CreateListItem(ctx, "user-1", "", "Milk")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateListItem without title",
|
||||||
|
call: func(svc *ListService) error {
|
||||||
|
_, err := svc.CreateListItem(ctx, "user-1", "list-1", "")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: ErrListItemTitleMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeleteListItem without item id",
|
||||||
|
call: func(svc *ListService) error { return svc.DeleteListItem(ctx, "user-1", "") },
|
||||||
|
wantErr: ErrListItemIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateListItem without item id",
|
||||||
|
call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "", "Milk", false) },
|
||||||
|
wantErr: ErrListItemIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateListItem without title",
|
||||||
|
call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "item-1", "", false) },
|
||||||
|
wantErr: ErrListItemTitleMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetListItemTitle without item id",
|
||||||
|
call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "", "Milk") },
|
||||||
|
wantErr: ErrListItemIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetListItemTitle without title",
|
||||||
|
call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "item-1", "") },
|
||||||
|
wantErr: ErrListItemTitleMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SetListItemCompleted without item id",
|
||||||
|
call: func(svc *ListService) error { return svc.SetListItemCompleted(ctx, "user-1", "", true) },
|
||||||
|
wantErr: ErrListItemIDMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GetListItems without list id",
|
||||||
|
call: func(svc *ListService) error {
|
||||||
|
_, err := svc.GetListItems(ctx, "user-1", "")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
wantErr: ErrListIDMissing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
svc, repo, tx := newListService(t)
|
||||||
|
|
||||||
|
err := tt.call(svc)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, tt.wantErr)
|
||||||
|
assert.Zero(t, tx.calls, "no transaction may be started")
|
||||||
|
assertCallOrder(t, repo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPermutation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a []string
|
||||||
|
b []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "both empty", a: nil, b: []string{}, want: true},
|
||||||
|
{name: "same order", a: []string{"a", "b", "c"}, b: []string{"a", "b", "c"}, want: true},
|
||||||
|
{name: "reordered", a: []string{"a", "b", "c"}, b: []string{"c", "a", "b"}, want: true},
|
||||||
|
{name: "different case", a: []string{"ABC", "def"}, b: []string{"DEF", "abc"}, want: true},
|
||||||
|
{name: "first is shorter", a: []string{"a", "b"}, b: []string{"a", "b", "c"}, want: false},
|
||||||
|
{name: "second is shorter", a: []string{"a", "b", "c"}, b: []string{"a", "b"}, want: false},
|
||||||
|
{name: "same length, different element", a: []string{"a", "b"}, b: []string{"a", "c"}, want: false},
|
||||||
|
{name: "duplicate in first", a: []string{"a", "a"}, b: []string{"a", "b"}, want: false},
|
||||||
|
{name: "duplicate in second", a: []string{"a", "b"}, b: []string{"a", "a"}, want: false},
|
||||||
|
{name: "same duplicate on both sides", a: []string{"a", "a"}, b: []string{"a", "a"}, want: false},
|
||||||
|
{name: "duplicate differing only in case", a: []string{"a", "b"}, b: []string{"a", "A"}, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isPermutation(tt.a, tt.b))
|
||||||
|
assert.Equal(t, tt.want, isPermutation(tt.b, tt.a), "must be symmetric")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistrationService struct {
|
||||||
|
tx Transactor
|
||||||
|
UserService *UserService
|
||||||
|
InviteService *InviteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistrationService(tx Transactor, u *UserService, i *InviteService) *RegistrationService {
|
||||||
|
return &RegistrationService{tx: tx, UserService: u, InviteService: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RegistrationService) Register(ctx context.Context, inviteCode string, email string, username string, password string) (*User, error) {
|
||||||
|
invite, err := s.InviteService.GetInvite(ctx, inviteCode)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *User
|
||||||
|
err = s.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
user, err = s.UserService.CreateUser(ctx, email, username, password)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.InviteService.ConsumeInvite(ctx, invite.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "user registration complete",
|
||||||
|
slog.String("user_id", user.ID),
|
||||||
|
slog.String("invite_id", invite.ID))
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Transactor interface {
|
||||||
|
WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
|
||||||
|
}
|
||||||
+41
-3
@@ -8,6 +8,13 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserIDMissing = errors.New("user id is required")
|
||||||
|
ErrEmailMissing = errors.New("email is required")
|
||||||
|
ErrNameMissing = errors.New("name is required")
|
||||||
|
ErrPasswordMissing = errors.New("password is required")
|
||||||
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -17,6 +24,9 @@ type User struct {
|
|||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
|
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
|
||||||
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
|
ChangePassword(ctx context.Context, userID string, passwordHash string) error
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
@@ -29,13 +39,13 @@ func NewUserService(r UserRepository) *UserService {
|
|||||||
|
|
||||||
func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) {
|
func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) {
|
||||||
if len(email) == 0 {
|
if len(email) == 0 {
|
||||||
return nil, errors.New("email is required")
|
return nil, ErrEmailMissing
|
||||||
}
|
}
|
||||||
if len(name) == 0 {
|
if len(name) == 0 {
|
||||||
return nil, errors.New("name is required")
|
return nil, ErrNameMissing
|
||||||
}
|
}
|
||||||
if len(password) == 0 {
|
if len(password) == 0 {
|
||||||
return nil, errors.New("password is required")
|
return nil, ErrPasswordMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -45,3 +55,31 @@ func (s *UserService) CreateUser(ctx context.Context, email string, name string,
|
|||||||
|
|
||||||
return s.repo.CreateUser(ctx, email, name, string(hash))
|
return s.repo.CreateUser(ctx, email, name, string(hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
if len(userID) == 0 {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error {
|
||||||
|
if len(userID) == 0 {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
if len(password) == 0 {
|
||||||
|
return ErrPasswordMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.ChangePassword(ctx, userID, string(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
|
return s.repo.GetUserByEmail(ctx, email)
|
||||||
|
}
|
||||||
|
|||||||
+11
-12
@@ -7,21 +7,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthRepo struct {
|
type AuthRepo struct {
|
||||||
db *sql.DB
|
Repo
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuthRepo(db *sql.DB) *AuthRepo {
|
|
||||||
return &AuthRepo{db: db}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) {
|
func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) {
|
||||||
user := &domain.AuthUser{}
|
user := &domain.AuthUser{}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"SELECT id, email, name, password_hash FROM users WHERE id = $1",
|
"SELECT id, email, name, password_hash FROM users WHERE id = $1",
|
||||||
id,
|
id,
|
||||||
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
||||||
@@ -35,11 +31,14 @@ func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser
|
|||||||
func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) {
|
func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) {
|
||||||
user := &domain.AuthUser{}
|
user := &domain.AuthUser{}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"SELECT id, email, name, password_hash FROM users WHERE email = $1",
|
"SELECT id, email, name, password_hash FROM users WHERE email = $1",
|
||||||
email,
|
email,
|
||||||
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, domain.ErrEmailNotFound
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error {
|
func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||||
userID, tokenHash, expiresAt,
|
userID, tokenHash, expiresAt,
|
||||||
)
|
)
|
||||||
@@ -60,7 +59,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
|
|||||||
|
|
||||||
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
var userID string
|
var userID string
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id",
|
"DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id",
|
||||||
tokenHash,
|
tokenHash,
|
||||||
).Scan(&userID)
|
).Scan(&userID)
|
||||||
@@ -75,7 +74,7 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
|
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to revoke refresh token: %w", err)
|
return fmt.Errorf("failed to revoke refresh token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,7 +83,7 @@ func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error {
|
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error {
|
||||||
_, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
|
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to revoke refresh tokens: %w", err)
|
return fmt.Errorf("failed to revoke refresh tokens: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
var m = pgtype.NewMap()
|
||||||
|
|
||||||
|
type ExerciseRepo struct {
|
||||||
|
Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExerciseRepo) CreateExercise(ctx context.Context) (*domain.Exercise, error) {
|
||||||
|
_ = ctx
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExerciseRepo) DeleteExercise(ctx context.Context, id string) error {
|
||||||
|
_, _ = ctx, id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExerciseRepo) GetExercises(ctx context.Context, offset int, count int) ([]domain.Exercise, error) {
|
||||||
|
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||||
|
"SELECT id, name, equipment, metric, load, tags, notes, modified_at FROM exercises WHERE user_id IS NULL ORDER BY modified_at DESC OFFSET $1 LIMIT $2",
|
||||||
|
offset, count,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get exercises: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
exercises := make([]domain.Exercise, 0, count)
|
||||||
|
for rows.Next() {
|
||||||
|
var e domain.Exercise
|
||||||
|
err = rows.Scan(&e.ID, &e.Name, (*domain.EquipmentSet)(&e.Equipment), &e.Metric, &e.Load, m.SQLScanner(&e.Tags), &e.Notes, &e.ModifiedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
exercises = append(exercises, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercises, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExerciseRepo) CountExercises(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM exercises",
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count exercises: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InviteRepo struct {
|
||||||
|
Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) {
|
||||||
|
var id string
|
||||||
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
|
"INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
inviterUserID, code, expiresAt,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to insert invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.Invite{
|
||||||
|
ID: id,
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ConsumedAt: nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
|
||||||
|
res, err := r.conn(ctx).ExecContext(ctx,
|
||||||
|
"DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL",
|
||||||
|
inviteID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete invite: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if affected < 1 {
|
||||||
|
// It's more of an assumption,
|
||||||
|
// but unless I encounter this being wrong, I'll keep it.
|
||||||
|
return domain.ErrInviteConsumed
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
|
||||||
|
res, err := r.conn(ctx).ExecContext(ctx,
|
||||||
|
"UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL",
|
||||||
|
inviteeUserID, inviteID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update invite: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if affected < 1 {
|
||||||
|
return domain.ErrInviteInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) {
|
||||||
|
var invite domain.Invite
|
||||||
|
|
||||||
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
|
"SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1",
|
||||||
|
code,
|
||||||
|
).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, domain.ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get invite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) GetInvites(ctx context.Context, userID string, offset int, count int) ([]domain.Invite, error) {
|
||||||
|
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||||
|
"SELECT id, code, expires_at, consumed_at FROM invites WHERE inviter_user_id=$1 ORDER BY created_at DESC OFFSET $2 LIMIT $3",
|
||||||
|
userID, offset, count,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get invites: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invites []domain.Invite
|
||||||
|
for rows.Next() {
|
||||||
|
var i domain.Invite
|
||||||
|
err = rows.Scan(&i.ID, &i.Code, &i.ExpiresAt, &i.ConsumedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
invites = append(invites, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return invites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) CountInvites(ctx context.Context, userID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1",
|
||||||
|
userID,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) CountInvitesStructured(ctx context.Context, userID string) (*domain.InviteCounts, error) {
|
||||||
|
var counts domain.InviteCounts
|
||||||
|
conn := r.conn(ctx)
|
||||||
|
err := conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1 AND expires_at > NOW() AND consumed_at IS NULL",
|
||||||
|
userID,
|
||||||
|
).Scan(&counts.Active)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to count active invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1 AND expires_at < NOW() AND consumed_at IS NULL",
|
||||||
|
userID,
|
||||||
|
).Scan(&counts.Expired)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to count expired invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(consumed_at) FROM invites WHERE inviter_user_id=$1",
|
||||||
|
userID,
|
||||||
|
).Scan(&counts.Used)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to count consumed invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &counts, nil
|
||||||
|
}
|
||||||
+85
-51
@@ -5,29 +5,18 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListRepo struct {
|
type ListRepo struct {
|
||||||
db *sql.DB
|
Repo
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListRepo(db *sql.DB) *ListRepo {
|
func (r *ListRepo) CreateList(ctx context.Context, name string) (*domain.List, error) {
|
||||||
return &ListRepo{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) {
|
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list := &domain.List{Name: name}
|
list := &domain.List{Name: name}
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at, modified_at",
|
"INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at, modified_at",
|
||||||
name,
|
name,
|
||||||
).Scan(&list.ID, &list.CreatedAt, &list.ModifiedAt)
|
).Scan(&list.ID, &list.CreatedAt, &list.ModifiedAt)
|
||||||
@@ -35,33 +24,21 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string
|
|||||||
return nil, fmt.Errorf("failed to insert list: %w", err)
|
return nil, fmt.Errorf("failed to insert list: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx, "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to prepare user/list association statement: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := stmt.Close()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to close user/list association statement", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for _, userID := range userIDs {
|
|
||||||
if _, err = stmt.ExecContext(ctx, list.ID, userID); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to insert user/list association: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, fmt.Errorf("commit transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ListRepo) DeleteList(ctx context.Context, listID string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM lists WHERE id = $1", listID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) {
|
func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||||
"SELECT id, name, lists.created_at, modified_at FROM lists INNER JOIN list_users ON lists.id=list_users.list_id WHERE list_users.user_id = $1",
|
"SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true), lu.position FROM lists AS l INNER JOIN list_users AS lu ON l.id=lu.list_id WHERE lu.user_id = $1 ORDER BY lu.position",
|
||||||
userID,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,10 +49,10 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var lists []domain.List
|
lists := make([]domain.List, 0, 16)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l domain.List
|
var l domain.List
|
||||||
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt)
|
err = rows.Scan(&l.ID, &l.Name, &l.CreatedAt, &l.ModifiedAt, &l.TotalItems, &l.CompletedItems, &l.Position)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,11 +61,10 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return lists, nil
|
return lists, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
|
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
"INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
|
"INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
|
||||||
listID, userID,
|
listID, userID,
|
||||||
)
|
)
|
||||||
@@ -100,7 +76,7 @@ func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
|
func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
"DELETE FROM list_users WHERE list_id = $1 AND user_id = $2",
|
"DELETE FROM list_users WHERE list_id = $1 AND user_id = $2",
|
||||||
listID, userID,
|
listID, userID,
|
||||||
)
|
)
|
||||||
@@ -111,10 +87,46 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ListRepo) OrderLists(ctx context.Context, userID string, listIDs []string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
|
"UPDATE list_users AS lu SET position = o.idx - 1 FROM unnest($2::uuid[]) WITH ORDINALITY AS o(list_id, idx) WHERE lu.list_id = o.list_id AND lu.user_id=$1",
|
||||||
|
userID, listIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to order lists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ListRepo) LockUsersLists(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||||
|
"SELECT list_id FROM list_users WHERE user_id = $1 FOR UPDATE",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to lock users lists: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
ids := make([]string, 0, 16)
|
||||||
|
for rows.Next() {
|
||||||
|
var listID string
|
||||||
|
err = rows.Scan(&listID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read list id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, listID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
|
func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
|
||||||
var cnt int
|
var cnt int
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2",
|
"SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2",
|
||||||
listID, userID,
|
listID, userID,
|
||||||
).Scan(&cnt)
|
).Scan(&cnt)
|
||||||
@@ -128,7 +140,7 @@ func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID strin
|
|||||||
func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
|
func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
|
||||||
var cnt int
|
var cnt int
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2",
|
"SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2",
|
||||||
listItemID, userID,
|
listItemID, userID,
|
||||||
).Scan(&cnt)
|
).Scan(&cnt)
|
||||||
@@ -142,7 +154,7 @@ func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string,
|
|||||||
func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) {
|
func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) {
|
||||||
l := &domain.ListItem{Title: title}
|
l := &domain.ListItem{Title: title}
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at",
|
"INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at",
|
||||||
listID, title,
|
listID, title,
|
||||||
).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
|
).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
|
||||||
@@ -150,11 +162,21 @@ func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title stri
|
|||||||
return nil, fmt.Errorf("failed to insert list item: %w", err)
|
return nil, fmt.Errorf("failed to insert list item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.ListID = listID
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM list_items WHERE id = $1", listItemID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete list item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
|
func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error {
|
||||||
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3",
|
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3",
|
||||||
title, isCompleted, listItemID,
|
title, isCompleted, listItemID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -164,8 +186,19 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ListRepo) SetListItemTitle(ctx context.Context, listItemID string, title string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, modified_at = NOW() WHERE id = $2",
|
||||||
|
title, listItemID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update list item title: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
|
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error {
|
||||||
_, err := r.db.ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
|
_, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2",
|
||||||
isCompleted, listItemID,
|
isCompleted, listItemID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,8 +209,8 @@ func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) {
|
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) {
|
||||||
rows, err := r.db.QueryContext(ctx,
|
rows, err := r.conn(ctx).QueryContext(ctx,
|
||||||
"SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1",
|
"SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1 ORDER BY is_completed, modified_at DESC",
|
||||||
listID,
|
listID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,7 +221,7 @@ func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.Li
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var items []domain.ListItem
|
items := make([]domain.ListItem, 0, 32)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l domain.ListItem
|
var l domain.ListItem
|
||||||
err = rows.Scan(&l.ID, &l.Title, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
|
err = rows.Scan(&l.ID, &l.Title, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
|
||||||
@@ -196,6 +229,7 @@ func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.Li
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.ListID = listID
|
||||||
items = append(items, l)
|
items = append(items, l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transaction interface {
|
||||||
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
*Transactor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepo(t *Transactor) Repo {
|
||||||
|
return Repo{t}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) conn(ctx context.Context) Transaction {
|
||||||
|
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
|
|
||||||
|
type txKey struct{}
|
||||||
|
|
||||||
|
type Transactor struct {
|
||||||
|
db *sql.DB
|
||||||
|
sp atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransactor(db *sql.DB) *Transactor {
|
||||||
|
return &Transactor{db: db, sp: atomic.Uint64{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transactor) WithinTx(ctx context.Context, fn func(context.Context) error) error {
|
||||||
|
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
|
||||||
|
return t.withinSavepoint(ctx, tx, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := t.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if err = fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transactor) withinSavepoint(ctx context.Context, tx *sql.Tx, fn func(context.Context) error) error {
|
||||||
|
name := fmt.Sprintf("sp_%d", t.sp.Add(1))
|
||||||
|
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(ctx); err != nil {
|
||||||
|
_, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type unrelatedKey struct{}
|
||||||
|
|
||||||
|
func newTransactor(t *testing.T) (*Transactor, sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
assert.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
_ = db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return NewTransactor(db), mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRepo(t *testing.T) {
|
||||||
|
tr, _ := newTransactor(t)
|
||||||
|
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
assert.Same(t, tr, repo.Transactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepo_Conn(t *testing.T) {
|
||||||
|
t.Run("falls back to the pool without a transaction", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
mock.ExpectExec("DELETE FROM users WHERE id = $1").
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
assert.Same(t, tr.db, repo.conn(ctx))
|
||||||
|
|
||||||
|
_, err := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns the transaction carried by the context", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
tx, err := tr.db.BeginTx(context.Background(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), txKey{}, tx)
|
||||||
|
assert.Same(t, tx, repo.conn(ctx))
|
||||||
|
|
||||||
|
require.NoError(t, tx.Rollback())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores values stored under other keys", func(t *testing.T) {
|
||||||
|
tr, _ := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), unrelatedKey{}, "irrelevant")
|
||||||
|
|
||||||
|
assert.Same(t, tr.db, repo.conn(ctx))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores a value of the wrong type under txKey", func(t *testing.T) {
|
||||||
|
tr, _ := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), txKey{}, "not a transaction")
|
||||||
|
|
||||||
|
assert.Same(t, tr.db, repo.conn(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepo_WithinTxIsPromoted(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("DELETE FROM users WHERE id = $1").
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
err := repo.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
_, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
|
||||||
|
return execErr
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepo_SiblingReposShareTheTransaction(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
users := NewRepo(tr)
|
||||||
|
lists := NewRepo(tr)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("INSERT INTO lists (name) VALUES ($1)").
|
||||||
|
WithArgs("Groceries").
|
||||||
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
mock.ExpectExec("DELETE FROM users WHERE id = $1").
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
assert.Same(t, lists.conn(ctx), users.conn(ctx))
|
||||||
|
|
||||||
|
if _, err := lists.conn(ctx).ExecContext(ctx, "INSERT INTO lists (name) VALUES ($1)", "Groceries"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := users.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactor_WithinTx(t *testing.T) {
|
||||||
|
t.Run("commits and routes statements through the tx", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
repo := NewRepo(tr)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("DELETE FROM users WHERE id = $1").
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
tx, ok := ctx.Value(txKey{}).(*sql.Tx)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Same(t, tx, repo.conn(ctx))
|
||||||
|
|
||||||
|
_, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1")
|
||||||
|
return execErr
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("callback error rolls back and propagates", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
fnErr := errors.New("business rule violated")
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
return fnErr
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, fnErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("begin error skips the callback", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
beginErr := errors.New("too many connections")
|
||||||
|
mock.ExpectBegin().WillReturnError(beginErr)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, beginErr)
|
||||||
|
assert.False(t, called)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("commit error is returned", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
commitErr := errors.New("could not serialize access")
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectCommit().WillReturnError(commitErr)
|
||||||
|
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, commitErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactor_WithinTxNested(t *testing.T) {
|
||||||
|
t.Run("reuses the outer tx and releases the savepoint", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
var outerTx *sql.Tx
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
outerTx = ctx.Value(txKey{}).(*sql.Tx)
|
||||||
|
|
||||||
|
return tr.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
assert.Same(t, outerTx, ctx.Value(txKey{}).(*sql.Tx))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inner error rolls back to the savepoint", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("ROLLBACK TO SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
innerErr := errors.New("nested failure")
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
return tr.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
return innerErr
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, innerErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("savepoint creation error skips the callback", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
spErr := errors.New("savepoint failed")
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_1").WillReturnError(spErr)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
called := false
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
return tr.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, spErr)
|
||||||
|
assert.False(t, called)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("release error is returned", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
releaseErr := errors.New("no such savepoint")
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnError(releaseErr)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
return tr.WithinTx(ctx, func(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, releaseErr)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("each savepoint gets its own name", func(t *testing.T) {
|
||||||
|
tr, mock := newTransactor(t)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectExec("RELEASE SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
noop := func(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
err := tr.WithinTx(context.Background(), func(ctx context.Context) error {
|
||||||
|
if err := tr.WithinTx(ctx, noop); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tr.WithinTx(ctx, noop)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "database/sql"
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
*Transactor
|
||||||
|
Auth *AuthRepo
|
||||||
|
Invite *InviteRepo
|
||||||
|
List *ListRepo
|
||||||
|
User *UserRepo
|
||||||
|
Exercise *ExerciseRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
t := NewTransactor(db)
|
||||||
|
r := NewRepo(t)
|
||||||
|
return &Store{
|
||||||
|
Transactor: t,
|
||||||
|
Auth: &AuthRepo{r},
|
||||||
|
Invite: &InviteRepo{r},
|
||||||
|
List: &ListRepo{r},
|
||||||
|
User: &UserRepo{r},
|
||||||
|
Exercise: &ExerciseRepo{r},
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-16
@@ -2,30 +2,19 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepo struct {
|
type UserRepo struct {
|
||||||
db *sql.DB
|
Repo
|
||||||
}
|
|
||||||
|
|
||||||
func NewUserRepo(db *sql.DB) *UserRepo {
|
|
||||||
return &UserRepo{db: db}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, passwordHash string) (*domain.User, error) {
|
func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, passwordHash string) (*domain.User, error) {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
user := &domain.User{Email: email, Name: name}
|
user := &domain.User{Email: email, Name: name}
|
||||||
|
|
||||||
err = tx.QueryRowContext(ctx,
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
"INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
|
"INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
|
||||||
email, name, passwordHash,
|
email, name, passwordHash,
|
||||||
).Scan(&user.ID, &user.CreatedAt)
|
).Scan(&user.ID, &user.CreatedAt)
|
||||||
@@ -33,8 +22,42 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa
|
|||||||
return nil, fmt.Errorf("failed to insert user: %w", err)
|
return nil, fmt.Errorf("failed to insert user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
return user, nil
|
||||||
return nil, fmt.Errorf("commit transaction: %w", err)
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
|
"DELETE FROM users WHERE id = $1",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error {
|
||||||
|
_, err := r.conn(ctx).ExecContext(ctx,
|
||||||
|
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||||
|
passwordHash, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
|
user := &domain.User{}
|
||||||
|
|
||||||
|
err := r.conn(ctx).QueryRowContext(ctx,
|
||||||
|
"SELECT id, email, name FROM users WHERE email = $1",
|
||||||
|
email,
|
||||||
|
).Scan(&user.ID, &user.Email, &user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|||||||
+182
-163
@@ -2,188 +2,207 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
"github.com/robindittmar/dttmr-api/internal/domain"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"git.dittmar.dev/robin/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newUserRepo(t *testing.T) (*UserRepo, sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
assert.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
_ = db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return &UserRepo{Repo: NewRepo(NewTransactor(db))}, mock
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
insertUserQuery = `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at`
|
||||||
|
deleteUserQuery = `DELETE FROM users WHERE id = $1`
|
||||||
|
updatePassQuery = `UPDATE users SET password_hash = $1 WHERE id = $2`
|
||||||
|
selectUserQuery = `SELECT id, email, name FROM users WHERE email = $1`
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserRepo_CreateUser(t *testing.T) {
|
func TestUserRepo_CreateUser(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
repo := NewUserRepo(db)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
email := "test@example.com"
|
|
||||||
name := "Test User"
|
|
||||||
passwordHash := "hashedpassword123"
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
expectedUser := &domain.User{
|
|
||||||
ID: "1",
|
|
||||||
Email: email,
|
|
||||||
Name: name,
|
|
||||||
CreatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
mock.ExpectBegin()
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`).
|
createdAt := time.Date(2026, 9, 9, 10, 0, 0, 0, time.UTC)
|
||||||
WithArgs(email, name, passwordHash).
|
mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt))
|
WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
|
||||||
|
WillReturnRows(
|
||||||
mock.ExpectCommit()
|
sqlmock.NewRows([]string{"id", "created_at"}).
|
||||||
|
AddRow("2f1c...", createdAt),
|
||||||
user, err := repo.CreateUser(ctx, email, name, passwordHash)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expectedUser, user)
|
|
||||||
assert.NoError(t, mock.ExpectationsWereMet())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("begin_tx_error", func(t *testing.T) {
|
|
||||||
mock.ExpectBegin().WillReturnError(fmt.Errorf("tx error"))
|
|
||||||
|
|
||||||
user, err := repo.CreateUser(ctx, email, name, passwordHash)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "begin transaction")
|
|
||||||
assert.Nil(t, user)
|
|
||||||
assert.NoError(t, mock.ExpectationsWereMet())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("insert_error", func(t *testing.T) {
|
|
||||||
mock.ExpectBegin()
|
|
||||||
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`).
|
|
||||||
WithArgs(email, name, passwordHash).
|
|
||||||
WillReturnError(fmt.Errorf("insert error"))
|
|
||||||
mock.ExpectRollback()
|
|
||||||
|
|
||||||
user, err := repo.CreateUser(ctx, email, name, passwordHash)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "failed to insert user")
|
|
||||||
assert.Nil(t, user)
|
|
||||||
assert.NoError(t, mock.ExpectationsWereMet())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("commit_error", func(t *testing.T) {
|
|
||||||
mock.ExpectBegin()
|
|
||||||
mock.ExpectQuery(`^INSERT INTO users \(email, name, password_hash\) VALUES \(\$1, \$2, \$3\) RETURNING id, created_at$`).
|
|
||||||
WithArgs(email, name, passwordHash).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(expectedUser.ID, expectedUser.CreatedAt))
|
|
||||||
mock.ExpectCommit().WillReturnError(fmt.Errorf("commit error"))
|
|
||||||
|
|
||||||
user, err := repo.CreateUser(ctx, email, name, passwordHash)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "commit transaction")
|
|
||||||
assert.Nil(t, user)
|
|
||||||
assert.NoError(t, mock.ExpectationsWereMet())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserRepo_CreateUser2(t *testing.T) {
|
|
||||||
email := "test@example.com"
|
|
||||||
name := "Test User"
|
|
||||||
passwordHash := "hashedpassword123"
|
|
||||||
now := time.Now()
|
|
||||||
expectedID := "42"
|
|
||||||
|
|
||||||
insertQuery := regexp.QuoteMeta(
|
|
||||||
"INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
testCases := []struct {
|
user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
|
||||||
name string
|
|
||||||
setupMock func(mock sqlmock.Sqlmock)
|
|
||||||
expectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Success: User created perfectly",
|
|
||||||
setupMock: func(mock sqlmock.Sqlmock) {
|
|
||||||
mock.ExpectBegin()
|
|
||||||
|
|
||||||
rows := sqlmock.NewRows([]string{"id", "created_at"}).
|
|
||||||
AddRow(expectedID, now)
|
|
||||||
|
|
||||||
mock.ExpectQuery(insertQuery).
|
|
||||||
WithArgs(email, name, passwordHash).
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
mock.ExpectCommit()
|
|
||||||
},
|
|
||||||
expectedError: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Failure: Database connection fails on BeginTx",
|
|
||||||
setupMock: func(mock sqlmock.Sqlmock) {
|
|
||||||
mock.ExpectBegin().WillReturnError(errors.New("db connection failed"))
|
|
||||||
},
|
|
||||||
expectedError: "begin transaction: db connection failed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Failure: Query fails (e.g., duplicate email)",
|
|
||||||
setupMock: func(mock sqlmock.Sqlmock) {
|
|
||||||
mock.ExpectBegin()
|
|
||||||
|
|
||||||
mock.ExpectQuery(insertQuery).
|
|
||||||
WithArgs(email, name, passwordHash).
|
|
||||||
WillReturnError(errors.New("unique constraint violation"))
|
|
||||||
|
|
||||||
mock.ExpectRollback()
|
|
||||||
},
|
|
||||||
expectedError: "failed to insert user: unique constraint violation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Failure: Commit fails (e.g., network timeout)",
|
|
||||||
setupMock: func(mock sqlmock.Sqlmock) {
|
|
||||||
mock.ExpectBegin()
|
|
||||||
|
|
||||||
rows := sqlmock.NewRows([]string{"id", "created_at"}).
|
|
||||||
AddRow(expectedID, now)
|
|
||||||
|
|
||||||
mock.ExpectQuery(insertQuery).
|
|
||||||
WithArgs(email, name, passwordHash).
|
|
||||||
WillReturnRows(rows)
|
|
||||||
|
|
||||||
mock.ExpectCommit().WillReturnError(errors.New("commit timeout"))
|
|
||||||
},
|
|
||||||
expectedError: "commit transaction: commit timeout",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
db, mock, err := sqlmock.New()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
tc.setupMock(mock)
|
|
||||||
|
|
||||||
repo := NewUserRepo(db)
|
|
||||||
|
|
||||||
user, err := repo.CreateUser(context.Background(), email, name, passwordHash)
|
|
||||||
|
|
||||||
if tc.expectedError != "" {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), tc.expectedError)
|
|
||||||
assert.Nil(t, user)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
assert.Equal(t, expectedID, user.ID)
|
assert.Equal(t, &domain.User{
|
||||||
assert.Equal(t, email, user.Email)
|
ID: "2f1c...",
|
||||||
assert.Equal(t, name, user.Name)
|
Email: "robin@dittmar.dev",
|
||||||
assert.Equal(t, now, user.CreatedAt)
|
Name: "Robin",
|
||||||
}
|
CreatedAt: createdAt,
|
||||||
|
}, user)
|
||||||
|
})
|
||||||
|
|
||||||
assert.NoError(t, mock.ExpectationsWereMet())
|
t.Run("db error is wrapped", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
dbErr := errors.New("duplicate key value violates unique constraint")
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(insertUserQuery)).
|
||||||
|
WithArgs("robin@dittmar.dev", "Robin", "$2a$10$hash").
|
||||||
|
WillReturnError(dbErr)
|
||||||
|
|
||||||
|
user, err := repo.CreateUser(context.Background(), "robin@dittmar.dev", "Robin", "$2a$10$hash")
|
||||||
|
|
||||||
|
assert.Nil(t, user)
|
||||||
|
assert.ErrorIs(t, err, dbErr)
|
||||||
|
assert.ErrorContains(t, err, "failed to insert user")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserRepo_DeleteUser(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
assert.NoError(t, repo.DeleteUser(context.Background(), "user-1"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown id is not reported", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
|
||||||
|
WithArgs("does-not-exist").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||||
|
|
||||||
|
assert.NoError(t, repo.DeleteUser(context.Background(), "does-not-exist"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("db error is wrapped", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
dbErr := errors.New("connection reset")
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnError(dbErr)
|
||||||
|
|
||||||
|
err := repo.DeleteUser(context.Background(), "user-1")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, dbErr)
|
||||||
|
assert.ErrorContains(t, err, "failed to delete user")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRepo_ChangePassword(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
|
||||||
|
WithArgs("$2a$10$newhash", "user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
assert.NoError(t, repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("db error is wrapped", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
dbErr := errors.New("deadlock detected")
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(updatePassQuery)).
|
||||||
|
WithArgs("$2a$10$newhash", "user-1").
|
||||||
|
WillReturnError(dbErr)
|
||||||
|
|
||||||
|
err := repo.ChangePassword(context.Background(), "user-1", "$2a$10$newhash")
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, dbErr)
|
||||||
|
assert.ErrorContains(t, err, "failed to update user")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRepo_UsesTransactionFromContext(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(deleteUserQuery)).
|
||||||
|
WithArgs("user-1").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
tx, err := repo.db.BeginTx(context.Background(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), txKey{}, tx)
|
||||||
|
require.NoError(t, repo.DeleteUser(ctx, "user-1"))
|
||||||
|
require.NoError(t, tx.Commit())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserRepo_GetUserByEmail(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
|
||||||
|
WithArgs("robin@dittmar.dev").
|
||||||
|
WillReturnRows(
|
||||||
|
sqlmock.NewRows([]string{"id", "email", "name"}).
|
||||||
|
AddRow("user-1", "robin@dittmar.dev", "Robin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, user)
|
||||||
|
assert.Equal(t, "user-1", user.ID)
|
||||||
|
assert.Equal(t, "robin@dittmar.dev", user.Email)
|
||||||
|
assert.Equal(t, "Robin", user.Name)
|
||||||
|
assert.Zero(t, user.CreatedAt) // not selected by this query
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not found stays matchable via errors.Is", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
|
||||||
|
WithArgs("nobody@dittmar.dev").
|
||||||
|
WillReturnError(sql.ErrNoRows)
|
||||||
|
|
||||||
|
user, err := repo.GetUserByEmail(context.Background(), "nobody@dittmar.dev")
|
||||||
|
|
||||||
|
assert.Nil(t, user)
|
||||||
|
assert.ErrorIs(t, err, sql.ErrNoRows)
|
||||||
|
assert.ErrorContains(t, err, "failed to get user")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scan error on type mismatch", func(t *testing.T) {
|
||||||
|
repo, mock := newUserRepo(t)
|
||||||
|
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(selectUserQuery)).
|
||||||
|
WithArgs("robin@dittmar.dev").
|
||||||
|
WillReturnRows(
|
||||||
|
sqlmock.NewRows([]string{"id", "email", "name"}).
|
||||||
|
AddRow(nil, "robin@dittmar.dev", "Robin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user, err := repo.GetUserByEmail(context.Background(), "robin@dittmar.dev")
|
||||||
|
|
||||||
|
assert.Nil(t, user)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user