first commit

This commit is contained in:
2026-06-22 14:31:01 +05:00
commit 109e01a656
35 changed files with 2120 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/bin/
/tmp/
.env
*.test
coverage.out
.DS_Store
+10
View File
@@ -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
+10
View File
@@ -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>
+8
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -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"]
+16
View File
@@ -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
+31
View File
@@ -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 создаются в одном месте и передаются по интерфейсам.
+52
View File
@@ -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
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: task-api
static_configs:
- targets: ["api:8080"]
+48
View File
@@ -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:
+73
View File
@@ -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
)
+168
View File
@@ -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=
+117
View File
@@ -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...)
}
+60
View File
@@ -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[:]))
}
+74
View File
@@ -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
}
+81
View File
@@ -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"`
}
+44
View File
@@ -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
}
+177
View File
@@ -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
}
+14
View File
@@ -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
}
+119
View File
@@ -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)
}
+36
View File
@@ -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
}
}
+220
View File
@@ -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)
}
}
}
+62
View File
@@ -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
}
+39
View File
@@ -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
}
+49
View File
@@ -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)
}
+41
View File
@@ -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
}
+11
View File
@@ -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")
)
+127
View File
@@ -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)
}
+117
View File
@@ -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 }
+54
View File
@@ -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, "")
}
+57
View File
@@ -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 }
+69
View File
@@ -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);