commit 109e01a6569b6412625813ebaa10367422b46d11 Author: alisherrusinov Date: Mon Jun 22 14:31:01 2026 +0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..445dc74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/tmp/ +.env +*.test +coverage.out +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9e2f5a6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/secunda_test.iml b/.idea/secunda_test.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/secunda_test.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6370934 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c505375 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb8b004 --- /dev/null +++ b/README.md @@ -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 создаются в одном месте и передаются по интерфейсам. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..c65d235 --- /dev/null +++ b/cmd/api/main.go @@ -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) + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..9590da3 --- /dev/null +++ b/config.yaml @@ -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" diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml new file mode 100644 index 0000000..2da9ff3 --- /dev/null +++ b/deploy/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: task-api + static_configs: + - targets: ["api:8080"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1e38e5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ae2d90 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..36bbe2e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..b9ea4fa --- /dev/null +++ b/internal/app/app.go @@ -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...) +} diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..4a9b85d --- /dev/null +++ b/internal/cache/redis.go @@ -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[:])) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fd55901 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/domain/models.go b/internal/domain/models.go new file mode 100644 index 0000000..16e7458 --- /dev/null +++ b/internal/domain/models.go @@ -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"` +} diff --git a/internal/email/sender.go b/internal/email/sender.go new file mode 100644 index 0000000..81d7105 --- /dev/null +++ b/internal/email/sender.go @@ -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 +} diff --git a/internal/handler/handlers.go b/internal/handler/handlers.go new file mode 100644 index 0000000..e83f79c --- /dev/null +++ b/internal/handler/handlers.go @@ -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 +} diff --git a/internal/httpx/context.go b/internal/httpx/context.go new file mode 100644 index 0000000..552594e --- /dev/null +++ b/internal/httpx/context.go @@ -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 +} diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go new file mode 100644 index 0000000..55e658e --- /dev/null +++ b/internal/httpx/middleware.go @@ -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) +} diff --git a/internal/httpx/response.go b/internal/httpx/response.go new file mode 100644 index 0000000..d0d0021 --- /dev/null +++ b/internal/httpx/response.go @@ -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 + } +} diff --git a/internal/repository/mysql/tasks.go b/internal/repository/mysql/tasks.go new file mode 100644 index 0000000..0f7172c --- /dev/null +++ b/internal/repository/mysql/tasks.go @@ -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) +} diff --git a/internal/repository/mysql/tasks_integration_test.go b/internal/repository/mysql/tasks_integration_test.go new file mode 100644 index 0000000..8510269 --- /dev/null +++ b/internal/repository/mysql/tasks_integration_test.go @@ -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) + } + } +} diff --git a/internal/repository/mysql/teams.go b/internal/repository/mysql/teams.go new file mode 100644 index 0000000..fc4bdfe --- /dev/null +++ b/internal/repository/mysql/teams.go @@ -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 +} diff --git a/internal/repository/mysql/users.go b/internal/repository/mysql/users.go new file mode 100644 index 0000000..41b19d5 --- /dev/null +++ b/internal/repository/mysql/users.go @@ -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 +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..9d7a6ac --- /dev/null +++ b/internal/service/auth.go @@ -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) +} diff --git a/internal/service/contracts.go b/internal/service/contracts.go new file mode 100644 index 0000000..6e554b3 --- /dev/null +++ b/internal/service/contracts.go @@ -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 +} diff --git a/internal/service/errors.go b/internal/service/errors.go new file mode 100644 index 0000000..68a3d75 --- /dev/null +++ b/internal/service/errors.go @@ -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") +) diff --git a/internal/service/task.go b/internal/service/task.go new file mode 100644 index 0000000..3ca971f --- /dev/null +++ b/internal/service/task.go @@ -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) +} diff --git a/internal/service/task_test.go b/internal/service/task_test.go new file mode 100644 index 0000000..0e49fe6 --- /dev/null +++ b/internal/service/task_test.go @@ -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 } diff --git a/internal/service/team.go b/internal/service/team.go new file mode 100644 index 0000000..ad879b2 --- /dev/null +++ b/internal/service/team.go @@ -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, "") +} diff --git a/internal/service/team_test.go b/internal/service/team_test.go new file mode 100644 index 0000000..a50e2a3 --- /dev/null +++ b/internal/service/team_test.go @@ -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 } diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..b421146 --- /dev/null +++ b/migrations/001_init.sql @@ -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);