first commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/bin/
|
||||
/tmp/
|
||||
.env
|
||||
*.test
|
||||
coverage.out
|
||||
.DS_Store
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/secunda_test.iml" filepath="$PROJECT_DIR$/.idea/secunda_test.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/task-api ./cmd/api
|
||||
|
||||
FROM alpine:3.20
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=build /out/task-api /app/task-api
|
||||
COPY config.yaml /app/config.yaml
|
||||
COPY migrations /app/migrations
|
||||
EXPOSE 8080
|
||||
CMD ["/app/task-api"]
|
||||
@@ -0,0 +1,16 @@
|
||||
.PHONY: run test up down migrate
|
||||
|
||||
run:
|
||||
go run ./cmd/api
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
up:
|
||||
docker compose up --build
|
||||
|
||||
down:
|
||||
docker compose down -v
|
||||
|
||||
migrate:
|
||||
docker compose exec api /app/task-api -migrate
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task Service
|
||||
|
||||
REST API для управления задачами в командах: JWT, роли, аудит изменений, Redis-кеш, MySQL, rate limiting, circuit breaker и Prometheus.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
API: `http://localhost:8080`
|
||||
Метрики: `http://localhost:8080/metrics`
|
||||
|
||||
## Основные эндпоинты
|
||||
|
||||
- `POST /api/v1/register`
|
||||
- `POST /api/v1/login`
|
||||
- `POST /api/v1/teams`
|
||||
- `GET /api/v1/teams`
|
||||
- `POST /api/v1/teams/{id}/invite`
|
||||
- `POST /api/v1/tasks`
|
||||
- `GET /api/v1/tasks?team_id=1&status=todo&assignee_id=5&page=1&page_size=20`
|
||||
- `PUT /api/v1/tasks/{id}`
|
||||
- `GET /api/v1/tasks/{id}/history`
|
||||
- `GET /api/v1/reports/team-summary`
|
||||
- `GET /api/v1/reports/top-creators`
|
||||
- `GET /api/v1/reports/invalid-assignees`
|
||||
|
||||
## Архитектура
|
||||
|
||||
DI собран в `internal/app.Container`: конфиг, DB pool, Redis, репозитории, сервисы, middleware и HTTP server создаются в одном месте и передаются по интерфейсам.
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"secunda-test/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
migrateOnly := flag.Bool("migrate", false, "run migrations and exit")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
container, err := app.New(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer container.Close()
|
||||
|
||||
if *migrateOnly {
|
||||
if err := container.Migrate(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := container.Migrate(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := container.Server.ListenAndServe(); err != nil {
|
||||
log.Printf("http server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("listening on %s", container.Config.HTTP.Addr)
|
||||
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := container.Server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("graceful shutdown failed: %v", err)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
http:
|
||||
addr: ":8080"
|
||||
database:
|
||||
dsn: "app:app@tcp(localhost:3306)/task_service?parseTime=true&multiStatements=true"
|
||||
max_open_conns: 25
|
||||
max_idle_conns: 10
|
||||
redis:
|
||||
addr: "localhost:6379"
|
||||
ttl_seconds: 300
|
||||
jwt:
|
||||
secret: "dev-secret"
|
||||
ttl_minutes: 1440
|
||||
rate_limit:
|
||||
requests_per_minute: 100
|
||||
email:
|
||||
endpoint: "mock://email"
|
||||
@@ -0,0 +1,7 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: task-api
|
||||
static_configs:
|
||||
- targets: ["api:8080"]
|
||||
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
APP_CONFIG: /app/config.yaml
|
||||
DB_DSN: app:app@tcp(mysql:3306)/task_service?parseTime=true&multiStatements=true
|
||||
REDIS_ADDR: redis:6379
|
||||
JWT_SECRET: change-me
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
environment:
|
||||
MYSQL_DATABASE: task_service
|
||||
MYSQL_USER: app
|
||||
MYSQL_PASSWORD: app
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.52.0
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
@@ -0,0 +1,73 @@
|
||||
module secunda-test
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
github.com/sony/gobreaker v1.0.0
|
||||
github.com/testcontainers/testcontainers-go v0.43.0
|
||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.43.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.2 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
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/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/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/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
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/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
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.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.43.0 h1:AaHaJoMolGB4Y5Q06bRYQSOCR3n1WE7iIFZJW1M9TG0=
|
||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.43.0/go.mod h1:EBP0BV3X80GE0muSleZ43AbRT625mzGCic1P1zntNLc=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
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.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
@@ -0,0 +1,117 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"secunda-test/internal/cache"
|
||||
"secunda-test/internal/config"
|
||||
"secunda-test/internal/email"
|
||||
"secunda-test/internal/handler"
|
||||
"secunda-test/internal/httpx"
|
||||
mysqlrepo "secunda-test/internal/repository/mysql"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
Config config.Config
|
||||
DB *sql.DB
|
||||
Redis *redis.Client
|
||||
Server *http.Server
|
||||
}
|
||||
|
||||
func New(ctx context.Context) (*Container, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr})
|
||||
|
||||
requests := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
|
||||
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
|
||||
prometheus.MustRegister(requests, duration)
|
||||
|
||||
users := mysqlrepo.NewUserRepository(db)
|
||||
teams := mysqlrepo.NewTeamRepository(db)
|
||||
tasks := mysqlrepo.NewTaskRepository(db)
|
||||
taskCache := cache.NewTaskCache(redisClient, time.Duration(cfg.Redis.TTLSeconds)*time.Second)
|
||||
emailSender := email.NewSender(cfg.Email.Endpoint)
|
||||
|
||||
authService := service.NewAuthService(users, cfg.JWT.Secret, time.Duration(cfg.JWT.TTLMinutes)*time.Minute)
|
||||
teamService := service.NewTeamService(teams, users, emailSender)
|
||||
taskService := service.NewTaskService(tasks, teams, taskCache)
|
||||
middleware := httpx.NewMiddleware(cfg.JWT.Secret, cfg.RateLimit.RequestsPerMinute, requests, duration)
|
||||
h := handler.New(authService, teamService, taskService, middleware)
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/", h.Routes())
|
||||
root.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
return &Container{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Redis: redisClient,
|
||||
Server: &http.Server{Addr: cfg.HTTP.Addr, Handler: root, ReadHeaderTimeout: 5 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Container) Migrate(ctx context.Context) error {
|
||||
files, err := filepath.Glob("migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = filepath.Glob("/app/migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, file := range files {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range strings.Split(string(data), ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := c.DB.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Container) Close() error {
|
||||
var errs []error
|
||||
if c.Redis != nil {
|
||||
errs = append(errs, c.Redis.Close())
|
||||
}
|
||||
if c.DB != nil {
|
||||
errs = append(errs, c.DB.Close())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TaskCache struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewTaskCache(client *redis.Client, ttl time.Duration) *TaskCache {
|
||||
return &TaskCache{client: client, ttl: ttl}
|
||||
}
|
||||
|
||||
func (c *TaskCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
|
||||
data, err := c.client.Get(ctx, c.key(filter)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var tasks []domain.Task
|
||||
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return tasks, true, nil
|
||||
}
|
||||
|
||||
func (c *TaskCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
|
||||
data, err := json.Marshal(tasks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.client.Set(ctx, c.key(filter), data, c.ttl).Err()
|
||||
}
|
||||
|
||||
func (c *TaskCache) DeleteTeam(ctx context.Context, teamID int64) error {
|
||||
iter := c.client.Scan(ctx, 0, fmt.Sprintf("tasks:team:%d:*", teamID), 100).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
_ = c.client.Del(ctx, iter.Val()).Err()
|
||||
}
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (c *TaskCache) key(filter domain.TaskFilter) string {
|
||||
raw, _ := json.Marshal(filter)
|
||||
sum := sha1.Sum(raw)
|
||||
return fmt.Sprintf("tasks:team:%d:%s", filter.TeamID, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTP struct {
|
||||
Addr string `yaml:"addr"`
|
||||
} `yaml:"http"`
|
||||
Database struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
} `yaml:"database"`
|
||||
Redis struct {
|
||||
Addr string `yaml:"addr"`
|
||||
TTLSeconds int `yaml:"ttl_seconds"`
|
||||
} `yaml:"redis"`
|
||||
JWT struct {
|
||||
Secret string `yaml:"secret"`
|
||||
TTLMinutes int `yaml:"ttl_minutes"`
|
||||
} `yaml:"jwt"`
|
||||
RateLimit struct {
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
} `yaml:"rate_limit"`
|
||||
Email struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
} `yaml:"email"`
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
path := getenv("APP_CONFIG", "config.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
override(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func override(cfg *Config) {
|
||||
if v := os.Getenv("HTTP_ADDR"); v != "" {
|
||||
cfg.HTTP.Addr = v
|
||||
}
|
||||
if v := os.Getenv("DB_DSN"); v != "" {
|
||||
cfg.Database.DSN = v
|
||||
}
|
||||
if v := os.Getenv("REDIS_ADDR"); v != "" {
|
||||
cfg.Redis.Addr = v
|
||||
}
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
cfg.JWT.Secret = v
|
||||
}
|
||||
if v := os.Getenv("RATE_LIMIT_RPM"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.RateLimit.RequestsPerMinute = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "owner"
|
||||
RoleAdmin Role = "admin"
|
||||
RoleMember Role = "member"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusTodo TaskStatus = "todo"
|
||||
StatusInProgress TaskStatus = "in_progress"
|
||||
StatusDone TaskStatus = "done"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
Role Role `json:"role,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status TaskStatus `json:"status"`
|
||||
AssigneeID *int64 `json:"assignee_id,omitempty"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TaskHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
ChangedBy int64 `json:"changed_by"`
|
||||
FieldName string `json:"field_name"`
|
||||
OldValue *string `json:"old_value,omitempty"`
|
||||
NewValue *string `json:"new_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TaskFilter struct {
|
||||
TeamID int64
|
||||
Status TaskStatus
|
||||
AssigneeID *int64
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type TeamSummary struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
MembersCount int64 `json:"members_count"`
|
||||
DoneLast7Days int64 `json:"done_last_7_days"`
|
||||
}
|
||||
|
||||
type TopCreator struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
TasksCreated int64 `json:"tasks_created"`
|
||||
Rank int64 `json:"rank"`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
endpoint string
|
||||
breaker *gobreaker.CircuitBreaker
|
||||
}
|
||||
|
||||
func NewSender(endpoint string) *Sender {
|
||||
return &Sender{
|
||||
endpoint: endpoint,
|
||||
breaker: gobreaker.NewCircuitBreaker(gobreaker.Settings{
|
||||
Name: "email-service",
|
||||
MaxRequests: 3,
|
||||
Interval: time.Minute,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, email, teamName string) error {
|
||||
_, err := s.breaker.Execute(func() (any, error) {
|
||||
if s.endpoint == "" {
|
||||
return nil, errors.New("email endpoint is empty")
|
||||
}
|
||||
if s.endpoint == "mock://email" {
|
||||
return nil, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/httpx"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
auth *service.AuthService
|
||||
teams *service.TeamService
|
||||
tasks *service.TaskService
|
||||
mw *httpx.Middleware
|
||||
}
|
||||
|
||||
func New(auth *service.AuthService, teams *service.TeamService, tasks *service.TaskService, mw *httpx.Middleware) *Handler {
|
||||
return &Handler{auth: auth, teams: teams, tasks: tasks, mw: mw}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/register", h.register)
|
||||
mux.HandleFunc("POST /api/v1/login", h.login)
|
||||
mux.Handle("POST /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.createTeam)))
|
||||
mux.Handle("GET /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.listTeams)))
|
||||
mux.Handle("POST /api/v1/teams/", h.mw.Auth(http.HandlerFunc(h.teamSubroutes)))
|
||||
mux.Handle("POST /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.createTask)))
|
||||
mux.Handle("GET /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.listTasks)))
|
||||
mux.Handle("PUT /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
|
||||
mux.Handle("GET /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
|
||||
mux.Handle("GET /api/v1/reports/team-summary", h.mw.Auth(http.HandlerFunc(h.teamSummary)))
|
||||
mux.Handle("GET /api/v1/reports/top-creators", h.mw.Auth(http.HandlerFunc(h.topCreators)))
|
||||
mux.Handle("GET /api/v1/reports/invalid-assignees", h.mw.Auth(http.HandlerFunc(h.invalidAssignees)))
|
||||
return h.mw.Chain(mux)
|
||||
}
|
||||
|
||||
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Email, Password, Name string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
id, err := h.auth.Register(r.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]int64{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Email, Password string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, err := h.auth.Login(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]string{"token": token})
|
||||
}
|
||||
|
||||
func (h *Handler) createTeam(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Name string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
team, err := h.teams.Create(r.Context(), mustUserID(r), req.Name)
|
||||
respond(w, team, http.StatusCreated, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listTeams(w http.ResponseWriter, r *http.Request) {
|
||||
teams, err := h.teams.List(r.Context(), mustUserID(r))
|
||||
respond(w, teams, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) teamSubroutes(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/teams/"), "/")
|
||||
if len(parts) == 2 && parts[1] == "invite" {
|
||||
teamID, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
var req struct{ Email string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := h.teams.Invite(r.Context(), mustUserID(r), teamID, req.Email)
|
||||
respond(w, map[string]string{"status": "invited"}, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req domain.Task
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
task, err := h.tasks.Create(r.Context(), mustUserID(r), req)
|
||||
respond(w, task, http.StatusCreated, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
teamID, _ := strconv.ParseInt(q.Get("team_id"), 10, 64)
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("page_size"))
|
||||
var assignee *int64
|
||||
if q.Get("assignee_id") != "" {
|
||||
v, _ := strconv.ParseInt(q.Get("assignee_id"), 10, 64)
|
||||
assignee = &v
|
||||
}
|
||||
tasks, err := h.tasks.List(r.Context(), mustUserID(r), domain.TaskFilter{
|
||||
TeamID: teamID, Status: domain.TaskStatus(q.Get("status")), AssigneeID: assignee, Page: page, PageSize: pageSize,
|
||||
})
|
||||
respond(w, tasks, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) taskSubroutes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||
parts := strings.Split(path, "/")
|
||||
id, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
if len(parts) == 2 && parts[1] == "history" && r.Method == http.MethodGet {
|
||||
history, err := h.tasks.History(r.Context(), mustUserID(r), id)
|
||||
respond(w, history, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && r.Method == http.MethodPut {
|
||||
var req domain.Task
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
task, err := h.tasks.Update(r.Context(), mustUserID(r), id, req)
|
||||
respond(w, task, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) teamSummary(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.TeamSummary(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) topCreators(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.TopCreators(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) invalidAssignees(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.InvalidAssignees(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any, status int, err error) {
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, status, v)
|
||||
}
|
||||
|
||||
func mustUserID(r *http.Request) int64 {
|
||||
id, _ := httpx.UserID(r.Context())
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package httpx
|
||||
|
||||
import "context"
|
||||
|
||||
type userIDKey struct{}
|
||||
|
||||
func WithUserID(ctx context.Context, userID int64) context.Context {
|
||||
return context.WithValue(ctx, userIDKey{}, userID)
|
||||
}
|
||||
|
||||
func UserID(ctx context.Context) (int64, bool) {
|
||||
v, ok := ctx.Value(userIDKey{}).(int64)
|
||||
return v, ok
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
jwtSecret []byte
|
||||
limit int
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
requests *prometheus.CounterVec
|
||||
duration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
func NewMiddleware(jwtSecret string, rpm int, requests *prometheus.CounterVec, duration *prometheus.HistogramVec) *Middleware {
|
||||
return &Middleware{jwtSecret: []byte(jwtSecret), limit: rpm, buckets: map[string]*bucket{}, requests: requests, duration: duration}
|
||||
}
|
||||
|
||||
func (m *Middleware) Chain(next http.Handler) http.Handler {
|
||||
return m.Metrics(m.RateLimit(next))
|
||||
}
|
||||
|
||||
func (m *Middleware) Auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
WriteError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
token, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(t *jwt.Token) (any, error) {
|
||||
return m.jwtSecret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid claims")
|
||||
return
|
||||
}
|
||||
sub, err := claims.GetSubject()
|
||||
if err != nil {
|
||||
if f, ok := claims["sub"].(float64); ok {
|
||||
sub = strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
}
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid subject")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithUserID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) RateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.RemoteAddr
|
||||
if id, ok := UserID(r.Context()); ok {
|
||||
key = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if !m.allow(key) {
|
||||
WriteError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) Metrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(rec, r)
|
||||
code := strconv.Itoa(rec.status)
|
||||
m.requests.WithLabelValues(r.Method, r.URL.Path, code).Inc()
|
||||
m.duration.WithLabelValues(r.Method, r.URL.Path).Observe(time.Since(start).Seconds())
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) allow(key string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
b := m.buckets[key]
|
||||
if b == nil || now.After(b.reset) {
|
||||
m.buckets[key] = &bucket{count: 1, reset: now.Add(time.Minute)}
|
||||
return true
|
||||
}
|
||||
if b.count >= m.limit {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, message string) {
|
||||
WriteJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func StatusFromError(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
return http.StatusUnauthorized
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, service.ErrNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, service.ErrBadRequest):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, service.ErrConflict):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type TaskRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTaskRepository(db *sql.DB) *TaskRepository { return &TaskRepository{db: db} }
|
||||
|
||||
func (r *TaskRepository) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks(team_id,title,description,status,assignee_id,created_by)
|
||||
VALUES(?,?,?,?,?,?)`,
|
||||
task.TeamID, task.Title, task.Description, valueOrDefault(string(task.Status), string(domain.StatusTodo)), task.AssigneeID, task.CreatedBy)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
var t domain.Task
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE id=?`, id).
|
||||
Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.Task{}, service.ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
old, err := r.Get(ctx, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE tasks SET title=?,description=?,status=?,assignee_id=? WHERE id=?`,
|
||||
task.Title, task.Description, task.Status, task.AssigneeID, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
addHistory := func(field, oldValue, newValue string) error {
|
||||
if oldValue == newValue {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO task_history(task_id,changed_by,field_name,old_value,new_value) VALUES(?,?,?,?,?)`,
|
||||
task.ID, changedBy, field, oldValue, newValue)
|
||||
return err
|
||||
}
|
||||
if err := addHistory("title", old.Title, task.Title); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("description", old.Description, task.Description); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("status", string(old.Status), string(task.Status)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("assignee_id", ptrString(old.AssigneeID), ptrString(task.AssigneeID)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
return r.Get(ctx, task.ID)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
args := []any{filter.TeamID}
|
||||
where := []string{"team_id=?"}
|
||||
if filter.Status != "" {
|
||||
where = append(where, "status=?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.AssigneeID != nil {
|
||||
where = append(where, "assignee_id=?")
|
||||
args = append(args, *filter.AssigneeID)
|
||||
}
|
||||
limit, offset := filter.PageSize, (filter.Page-1)*filter.PageSize
|
||||
args = append(args, limit, offset)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?`, strings.Join(where, " AND "))
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id,task_id,changed_by,field_name,old_value,new_value,created_at
|
||||
FROM task_history WHERE task_id=? ORDER BY created_at DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TaskHistory
|
||||
for rows.Next() {
|
||||
var h domain.TaskHistory
|
||||
if err := rows.Scan(&h.ID, &h.TaskID, &h.ChangedBy, &h.FieldName, &h.OldValue, &h.NewValue, &h.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.name, COUNT(DISTINCT tm.user_id) AS members_count,
|
||||
COUNT(DISTINCT CASE WHEN ta.status='done' AND ta.updated_at >= NOW() - INTERVAL 7 DAY THEN ta.id END) AS done_last_7_days
|
||||
FROM teams t
|
||||
LEFT JOIN team_members tm ON tm.team_id=t.id
|
||||
LEFT JOIN tasks ta ON ta.team_id=t.id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TeamSummary
|
||||
for rows.Next() {
|
||||
var s domain.TeamSummary
|
||||
if err := rows.Scan(&s.TeamID, &s.TeamName, &s.MembersCount, &s.DoneLast7Days); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT t.team_id, te.name AS team_name, t.created_by AS user_id, u.name AS user_name,
|
||||
COUNT(*) AS tasks_created,
|
||||
DENSE_RANK() OVER (PARTITION BY t.team_id ORDER BY COUNT(*) DESC) AS user_rank
|
||||
FROM tasks t
|
||||
JOIN teams te ON te.id=t.team_id
|
||||
JOIN users u ON u.id=t.created_by
|
||||
WHERE t.created_at >= NOW() - INTERVAL 1 MONTH
|
||||
GROUP BY t.team_id, te.name, t.created_by, u.name
|
||||
)
|
||||
SELECT team_id,team_name,user_id,user_name,tasks_created,user_rank
|
||||
FROM ranked WHERE user_rank <= 3 ORDER BY team_id,user_rank`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TopCreator
|
||||
for rows.Next() {
|
||||
var c domain.TopCreator
|
||||
if err := rows.Scan(&c.TeamID, &c.TeamName, &c.UserID, &c.UserName, &c.TasksCreated, &c.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT ta.id,ta.team_id,ta.title,ta.description,ta.status,ta.assignee_id,ta.created_by,ta.created_at,ta.updated_at
|
||||
FROM tasks ta
|
||||
LEFT JOIN team_members tm ON tm.team_id=ta.team_id AND tm.user_id=ta.assignee_id
|
||||
WHERE ta.assignee_id IS NOT NULL AND tm.user_id IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func valueOrDefault(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ptrString(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(*v)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build integration
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
func TestTaskRepositoryReportsWithMySQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dsn := testDSN(ctx, t)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
migrate(ctx, t, db)
|
||||
|
||||
repo := NewTaskRepository(db)
|
||||
if _, err := repo.TeamSummary(context.Background()); err != nil {
|
||||
t.Fatalf("team summary query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.TopCreators(context.Background()); err != nil {
|
||||
t.Fatalf("top creators query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.InvalidAssignees(context.Background()); err != nil {
|
||||
t.Fatalf("invalid assignees query failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testDSN(ctx context.Context, t *testing.T) string {
|
||||
t.Helper()
|
||||
if dsn := os.Getenv("TEST_MYSQL_DSN"); dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
container, err := tcmysql.Run(ctx,
|
||||
"mysql:8.4",
|
||||
tcmysql.WithDatabase("task_service_test"),
|
||||
tcmysql.WithUsername("app"),
|
||||
tcmysql.WithPassword("app"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("3306/tcp").WithStartupTimeout(2*time.Minute)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("start mysql container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
})
|
||||
dsn, err := container.ConnectionString(ctx, "parseTime=true", "multiStatements=true")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql dsn: %v", err)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "001_init.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range strings.Split(string(data), ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("migration failed: %v\nsql: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTeamRepository(db *sql.DB) *TeamRepository { return &TeamRepository{db: db} }
|
||||
|
||||
func (r *TeamRepository) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO teams(name,created_by) VALUES(?,?)`, name, createdBy)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return domain.Team{ID: id, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *TeamRepository) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO team_members(user_id,team_id,role) VALUES(?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role)`, userID, teamID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TeamRepository) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id,t.name,t.created_by,tm.role,t.created_at
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id=t.id
|
||||
WHERE tm.user_id=?
|
||||
ORDER BY t.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Team
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedBy, &t.Role, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TeamRepository) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
var role domain.Role
|
||||
err := r.db.QueryRowContext(ctx, `SELECT role FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type UserRepository struct{ db *sql.DB }
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} }
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO users(email,password_hash,name) VALUES(?,?,?)`, email, passwordHash, name)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE email=?`, email))
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE id=?`, id))
|
||||
}
|
||||
|
||||
func (r *UserRepository) scan(row *sql.Row) (domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.User{}, service.ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
users UserRepository
|
||||
jwtSecret []byte
|
||||
jwtTTL time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(users UserRepository, jwtSecret string, jwtTTL time.Duration) *AuthService {
|
||||
return &AuthService{users: users, jwtSecret: []byte(jwtSecret), jwtTTL: jwtTTL}
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, email, password, name string) (int64, error) {
|
||||
if email == "" || password == "" || name == "" {
|
||||
return 0, ErrBadRequest
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, email, string(hash), name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
claims := jwt.MapClaims{
|
||||
"sub": u.ID,
|
||||
"exp": time.Now().Add(s.jwtTTL).Unix(),
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, email, passwordHash, name string) (domain.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (domain.User, error)
|
||||
FindByID(ctx context.Context, id int64) (domain.User, error)
|
||||
}
|
||||
|
||||
type TeamRepository interface {
|
||||
Create(ctx context.Context, name string, createdBy int64) (domain.Team, error)
|
||||
AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error
|
||||
ListByUser(ctx context.Context, userID int64) ([]domain.Team, error)
|
||||
MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error)
|
||||
}
|
||||
|
||||
type TaskRepository interface {
|
||||
Create(ctx context.Context, task domain.Task) (domain.Task, error)
|
||||
Get(ctx context.Context, id int64) (domain.Task, error)
|
||||
Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error)
|
||||
List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error)
|
||||
History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error)
|
||||
TeamSummary(ctx context.Context) ([]domain.TeamSummary, error)
|
||||
TopCreators(ctx context.Context) ([]domain.TopCreator, error)
|
||||
InvalidAssignees(ctx context.Context) ([]domain.Task, error)
|
||||
}
|
||||
|
||||
type TaskCache interface {
|
||||
GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error)
|
||||
SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error
|
||||
DeleteTeam(ctx context.Context, teamID int64) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendInvite(ctx context.Context, email, teamName string) error
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
tasks TaskRepository
|
||||
teams TeamRepository
|
||||
cache TaskCache
|
||||
}
|
||||
|
||||
func NewTaskService(tasks TaskRepository, teams TeamRepository, cache TaskCache) *TaskService {
|
||||
return &TaskService{tasks: tasks, teams: teams, cache: cache}
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(ctx context.Context, userID int64, task domain.Task) (domain.Task, error) {
|
||||
if task.TeamID == 0 || task.Title == "" {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if task.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, *task.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
}
|
||||
task.CreatedBy = userID
|
||||
created, err := s.tasks.Create(ctx, task)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, task.TeamID)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *TaskService) List(ctx context.Context, userID int64, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
if filter.TeamID == 0 {
|
||||
return nil, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, filter.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 || filter.PageSize > 100 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
if cached, ok, err := s.cache.GetTasks(ctx, filter); err == nil && ok {
|
||||
return cached, nil
|
||||
}
|
||||
tasks, err := s.tasks.List(ctx, filter)
|
||||
if err == nil {
|
||||
_ = s.cache.SetTasks(ctx, filter, tasks)
|
||||
}
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *TaskService) Update(ctx context.Context, userID, taskID int64, patch domain.Task) (domain.Task, error) {
|
||||
current, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
role, ok, err := s.teams.MemberRole(ctx, current.TeamID, userID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if !ok || (role == domain.RoleMember && current.CreatedBy != userID && (current.AssigneeID == nil || *current.AssigneeID != userID)) {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if patch.Title != "" {
|
||||
current.Title = patch.Title
|
||||
}
|
||||
if patch.Description != "" {
|
||||
current.Description = patch.Description
|
||||
}
|
||||
if patch.Status != "" {
|
||||
current.Status = patch.Status
|
||||
}
|
||||
if patch.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, current.TeamID, *patch.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
current.AssigneeID = patch.AssigneeID
|
||||
}
|
||||
updated, err := s.tasks.Update(ctx, current, userID)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, current.TeamID)
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *TaskService) History(ctx context.Context, userID, taskID int64) ([]domain.TaskHistory, error) {
|
||||
task, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.tasks.History(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *TaskService) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return s.tasks.TeamSummary(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
return s.tasks.TopCreators(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
return s.tasks.InvalidAssignees(ctx)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskServiceCreateRequiresTeamMembership(t *testing.T) {
|
||||
svc := NewTaskService(&fakeTaskRepo{}, &fakeTeamRepo{members: map[int64]map[int64]domain.Role{}}, noopCache{})
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task"})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceRejectsAssigneeOutsideTeam(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(&fakeTaskRepo{}, teams, noopCache{})
|
||||
assigneeID := int64(20)
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task", AssigneeID: &assigneeID})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("expected bad request, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceMemberCanUpdateAssignedTask(t *testing.T) {
|
||||
assigneeID := int64(10)
|
||||
repo := &fakeTaskRepo{task: domain.Task{ID: 7, TeamID: 1, Title: "old", Status: domain.StatusTodo, AssigneeID: &assigneeID, CreatedBy: 99}}
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(repo, teams, noopCache{})
|
||||
updated, err := svc.Update(context.Background(), 10, 7, domain.Task{Title: "new", Status: domain.StatusDone})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Title != "new" || updated.Status != domain.StatusDone {
|
||||
t.Fatalf("unexpected task: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskRepo struct {
|
||||
task domain.Task
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
task.ID = 1
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
if r.task.ID == 0 {
|
||||
return domain.Task{}, ErrNotFound
|
||||
}
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeTaskRepo) TopCreators(ctx context.Context) ([]domain.TopCreator, error) { return nil, nil }
|
||||
func (r *fakeTaskRepo) InvalidAssignees(ctx context.Context) ([]domain.Task, error) { return nil, nil }
|
||||
|
||||
type fakeTeamRepo struct {
|
||||
members map[int64]map[int64]domain.Role
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
return domain.Team{ID: 1, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
if r.members == nil {
|
||||
r.members = map[int64]map[int64]domain.Role{}
|
||||
}
|
||||
if r.members[teamID] == nil {
|
||||
r.members[teamID] = map[int64]domain.Role{}
|
||||
}
|
||||
r.members[teamID][userID] = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
role, ok := r.members[teamID][userID]
|
||||
return role, ok, nil
|
||||
}
|
||||
|
||||
type noopCache struct{}
|
||||
|
||||
func (noopCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (noopCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopCache) DeleteTeam(ctx context.Context, teamID int64) error { return nil }
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamService struct {
|
||||
teams TeamRepository
|
||||
users UserRepository
|
||||
email EmailSender
|
||||
}
|
||||
|
||||
func NewTeamService(teams TeamRepository, users UserRepository, email EmailSender) *TeamService {
|
||||
return &TeamService{teams: teams, users: users, email: email}
|
||||
}
|
||||
|
||||
func (s *TeamService) Create(ctx context.Context, userID int64, name string) (domain.Team, error) {
|
||||
if name == "" {
|
||||
return domain.Team{}, ErrBadRequest
|
||||
}
|
||||
t, err := s.teams.Create(ctx, name, userID)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, t.ID, userID, domain.RoleOwner); err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
t.Role = domain.RoleOwner
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) List(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return s.teams.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *TeamService) Invite(ctx context.Context, actorID, teamID int64, email string) error {
|
||||
role, ok, err := s.teams.MemberRole(ctx, teamID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || (role != domain.RoleOwner && role != domain.RoleAdmin) {
|
||||
return ErrForbidden
|
||||
}
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, teamID, u.ID, domain.RoleMember); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.email.SendInvite(ctx, email, "")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTeamServiceInviteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
err := svc.Invite(context.Background(), 10, 1, "new@example.com")
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamServiceInviteAddsMember(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleAdmin}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
if err := svc.Invite(context.Background(), 10, 1, "new@example.com"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if role, ok := teams.members[1][20]; !ok || role != domain.RoleMember {
|
||||
t.Fatalf("expected invited member, got role=%q ok=%v", role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUserRepo struct {
|
||||
user domain.User
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
return domain.User{ID: 1, Email: email, PasswordHash: passwordHash, Name: name}, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
if r.user.Email == email {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
if r.user.ID == id {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
type fakeEmail struct{}
|
||||
|
||||
func (fakeEmail) SendInvite(ctx context.Context, email, teamName string) error { return nil }
|
||||
@@ -0,0 +1,69 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_teams_created_by FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
user_id BIGINT NOT NULL,
|
||||
team_id BIGINT NOT NULL,
|
||||
role ENUM('owner','admin','member') NOT NULL DEFAULT 'member',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, team_id),
|
||||
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
team_id BIGINT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status ENUM('todo','in_progress','done') NOT NULL DEFAULT 'todo',
|
||||
assignee_id BIGINT NULL,
|
||||
created_by BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_tasks_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tasks_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tasks_created_by FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_history (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
task_id BIGINT NOT NULL,
|
||||
changed_by BIGINT NOT NULL,
|
||||
field_name VARCHAR(64) NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_task_history_task FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_task_history_changed_by FOREIGN KEY (changed_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
task_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_task_comments_task FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_task_comments_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_team_members_team_role ON team_members(team_id, role);
|
||||
CREATE INDEX idx_tasks_team_status_assignee ON tasks(team_id, status, assignee_id, created_at);
|
||||
CREATE INDEX idx_tasks_team_created_by_created_at ON tasks(team_id, created_by, created_at);
|
||||
CREATE INDEX idx_tasks_assignee_team ON tasks(assignee_id, team_id);
|
||||
CREATE INDEX idx_task_history_task_created_at ON task_history(task_id, created_at);
|
||||
CREATE INDEX idx_task_comments_task_created_at ON task_comments(task_id, created_at);
|
||||
Reference in New Issue
Block a user