first commit
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"secunda-test/internal/cache"
|
||||
"secunda-test/internal/config"
|
||||
"secunda-test/internal/email"
|
||||
"secunda-test/internal/handler"
|
||||
"secunda-test/internal/httpx"
|
||||
mysqlrepo "secunda-test/internal/repository/mysql"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
Config config.Config
|
||||
DB *sql.DB
|
||||
Redis *redis.Client
|
||||
Server *http.Server
|
||||
}
|
||||
|
||||
func New(ctx context.Context) (*Container, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("mysql", cfg.Database.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr})
|
||||
|
||||
requests := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."}, []string{"method", "path", "status"})
|
||||
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path"})
|
||||
prometheus.MustRegister(requests, duration)
|
||||
|
||||
users := mysqlrepo.NewUserRepository(db)
|
||||
teams := mysqlrepo.NewTeamRepository(db)
|
||||
tasks := mysqlrepo.NewTaskRepository(db)
|
||||
taskCache := cache.NewTaskCache(redisClient, time.Duration(cfg.Redis.TTLSeconds)*time.Second)
|
||||
emailSender := email.NewSender(cfg.Email.Endpoint)
|
||||
|
||||
authService := service.NewAuthService(users, cfg.JWT.Secret, time.Duration(cfg.JWT.TTLMinutes)*time.Minute)
|
||||
teamService := service.NewTeamService(teams, users, emailSender)
|
||||
taskService := service.NewTaskService(tasks, teams, taskCache)
|
||||
middleware := httpx.NewMiddleware(cfg.JWT.Secret, cfg.RateLimit.RequestsPerMinute, requests, duration)
|
||||
h := handler.New(authService, teamService, taskService, middleware)
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/", h.Routes())
|
||||
root.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
return &Container{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Redis: redisClient,
|
||||
Server: &http.Server{Addr: cfg.HTTP.Addr, Handler: root, ReadHeaderTimeout: 5 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Container) Migrate(ctx context.Context) error {
|
||||
files, err := filepath.Glob("migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
files, err = filepath.Glob("/app/migrations/*.sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, file := range files {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range strings.Split(string(data), ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := c.DB.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Container) Close() error {
|
||||
var errs []error
|
||||
if c.Redis != nil {
|
||||
errs = append(errs, c.Redis.Close())
|
||||
}
|
||||
if c.DB != nil {
|
||||
errs = append(errs, c.DB.Close())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TaskCache struct {
|
||||
client *redis.Client
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewTaskCache(client *redis.Client, ttl time.Duration) *TaskCache {
|
||||
return &TaskCache{client: client, ttl: ttl}
|
||||
}
|
||||
|
||||
func (c *TaskCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
|
||||
data, err := c.client.Get(ctx, c.key(filter)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var tasks []domain.Task
|
||||
if err := json.Unmarshal(data, &tasks); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return tasks, true, nil
|
||||
}
|
||||
|
||||
func (c *TaskCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
|
||||
data, err := json.Marshal(tasks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.client.Set(ctx, c.key(filter), data, c.ttl).Err()
|
||||
}
|
||||
|
||||
func (c *TaskCache) DeleteTeam(ctx context.Context, teamID int64) error {
|
||||
iter := c.client.Scan(ctx, 0, fmt.Sprintf("tasks:team:%d:*", teamID), 100).Iterator()
|
||||
for iter.Next(ctx) {
|
||||
_ = c.client.Del(ctx, iter.Val()).Err()
|
||||
}
|
||||
return iter.Err()
|
||||
}
|
||||
|
||||
func (c *TaskCache) key(filter domain.TaskFilter) string {
|
||||
raw, _ := json.Marshal(filter)
|
||||
sum := sha1.Sum(raw)
|
||||
return fmt.Sprintf("tasks:team:%d:%s", filter.TeamID, hex.EncodeToString(sum[:]))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTP struct {
|
||||
Addr string `yaml:"addr"`
|
||||
} `yaml:"http"`
|
||||
Database struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
} `yaml:"database"`
|
||||
Redis struct {
|
||||
Addr string `yaml:"addr"`
|
||||
TTLSeconds int `yaml:"ttl_seconds"`
|
||||
} `yaml:"redis"`
|
||||
JWT struct {
|
||||
Secret string `yaml:"secret"`
|
||||
TTLMinutes int `yaml:"ttl_minutes"`
|
||||
} `yaml:"jwt"`
|
||||
RateLimit struct {
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
} `yaml:"rate_limit"`
|
||||
Email struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
} `yaml:"email"`
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
path := getenv("APP_CONFIG", "config.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
override(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func override(cfg *Config) {
|
||||
if v := os.Getenv("HTTP_ADDR"); v != "" {
|
||||
cfg.HTTP.Addr = v
|
||||
}
|
||||
if v := os.Getenv("DB_DSN"); v != "" {
|
||||
cfg.Database.DSN = v
|
||||
}
|
||||
if v := os.Getenv("REDIS_ADDR"); v != "" {
|
||||
cfg.Redis.Addr = v
|
||||
}
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
cfg.JWT.Secret = v
|
||||
}
|
||||
if v := os.Getenv("RATE_LIMIT_RPM"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
cfg.RateLimit.RequestsPerMinute = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "owner"
|
||||
RoleAdmin Role = "admin"
|
||||
RoleMember Role = "member"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusTodo TaskStatus = "todo"
|
||||
StatusInProgress TaskStatus = "in_progress"
|
||||
StatusDone TaskStatus = "done"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
PasswordHash string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
Role Role `json:"role,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status TaskStatus `json:"status"`
|
||||
AssigneeID *int64 `json:"assignee_id,omitempty"`
|
||||
CreatedBy int64 `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TaskHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
ChangedBy int64 `json:"changed_by"`
|
||||
FieldName string `json:"field_name"`
|
||||
OldValue *string `json:"old_value,omitempty"`
|
||||
NewValue *string `json:"new_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TaskFilter struct {
|
||||
TeamID int64
|
||||
Status TaskStatus
|
||||
AssigneeID *int64
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type TeamSummary struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
MembersCount int64 `json:"members_count"`
|
||||
DoneLast7Days int64 `json:"done_last_7_days"`
|
||||
}
|
||||
|
||||
type TopCreator struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
TasksCreated int64 `json:"tasks_created"`
|
||||
Rank int64 `json:"rank"`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
endpoint string
|
||||
breaker *gobreaker.CircuitBreaker
|
||||
}
|
||||
|
||||
func NewSender(endpoint string) *Sender {
|
||||
return &Sender{
|
||||
endpoint: endpoint,
|
||||
breaker: gobreaker.NewCircuitBreaker(gobreaker.Settings{
|
||||
Name: "email-service",
|
||||
MaxRequests: 3,
|
||||
Interval: time.Minute,
|
||||
Timeout: 10 * time.Second,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, email, teamName string) error {
|
||||
_, err := s.breaker.Execute(func() (any, error) {
|
||||
if s.endpoint == "" {
|
||||
return nil, errors.New("email endpoint is empty")
|
||||
}
|
||||
if s.endpoint == "mock://email" {
|
||||
return nil, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/httpx"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
auth *service.AuthService
|
||||
teams *service.TeamService
|
||||
tasks *service.TaskService
|
||||
mw *httpx.Middleware
|
||||
}
|
||||
|
||||
func New(auth *service.AuthService, teams *service.TeamService, tasks *service.TaskService, mw *httpx.Middleware) *Handler {
|
||||
return &Handler{auth: auth, teams: teams, tasks: tasks, mw: mw}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/register", h.register)
|
||||
mux.HandleFunc("POST /api/v1/login", h.login)
|
||||
mux.Handle("POST /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.createTeam)))
|
||||
mux.Handle("GET /api/v1/teams", h.mw.Auth(http.HandlerFunc(h.listTeams)))
|
||||
mux.Handle("POST /api/v1/teams/", h.mw.Auth(http.HandlerFunc(h.teamSubroutes)))
|
||||
mux.Handle("POST /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.createTask)))
|
||||
mux.Handle("GET /api/v1/tasks", h.mw.Auth(http.HandlerFunc(h.listTasks)))
|
||||
mux.Handle("PUT /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
|
||||
mux.Handle("GET /api/v1/tasks/", h.mw.Auth(http.HandlerFunc(h.taskSubroutes)))
|
||||
mux.Handle("GET /api/v1/reports/team-summary", h.mw.Auth(http.HandlerFunc(h.teamSummary)))
|
||||
mux.Handle("GET /api/v1/reports/top-creators", h.mw.Auth(http.HandlerFunc(h.topCreators)))
|
||||
mux.Handle("GET /api/v1/reports/invalid-assignees", h.mw.Auth(http.HandlerFunc(h.invalidAssignees)))
|
||||
return h.mw.Chain(mux)
|
||||
}
|
||||
|
||||
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Email, Password, Name string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
id, err := h.auth.Register(r.Context(), req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]int64{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Email, Password string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, err := h.auth.Login(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]string{"token": token})
|
||||
}
|
||||
|
||||
func (h *Handler) createTeam(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct{ Name string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
team, err := h.teams.Create(r.Context(), mustUserID(r), req.Name)
|
||||
respond(w, team, http.StatusCreated, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listTeams(w http.ResponseWriter, r *http.Request) {
|
||||
teams, err := h.teams.List(r.Context(), mustUserID(r))
|
||||
respond(w, teams, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) teamSubroutes(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/teams/"), "/")
|
||||
if len(parts) == 2 && parts[1] == "invite" {
|
||||
teamID, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
var req struct{ Email string }
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := h.teams.Invite(r.Context(), mustUserID(r), teamID, req.Email)
|
||||
respond(w, map[string]string{"status": "invited"}, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
var req domain.Task
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
task, err := h.tasks.Create(r.Context(), mustUserID(r), req)
|
||||
respond(w, task, http.StatusCreated, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
teamID, _ := strconv.ParseInt(q.Get("team_id"), 10, 64)
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("page_size"))
|
||||
var assignee *int64
|
||||
if q.Get("assignee_id") != "" {
|
||||
v, _ := strconv.ParseInt(q.Get("assignee_id"), 10, 64)
|
||||
assignee = &v
|
||||
}
|
||||
tasks, err := h.tasks.List(r.Context(), mustUserID(r), domain.TaskFilter{
|
||||
TeamID: teamID, Status: domain.TaskStatus(q.Get("status")), AssigneeID: assignee, Page: page, PageSize: pageSize,
|
||||
})
|
||||
respond(w, tasks, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) taskSubroutes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tasks/")
|
||||
parts := strings.Split(path, "/")
|
||||
id, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
if len(parts) == 2 && parts[1] == "history" && r.Method == http.MethodGet {
|
||||
history, err := h.tasks.History(r.Context(), mustUserID(r), id)
|
||||
respond(w, history, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && r.Method == http.MethodPut {
|
||||
var req domain.Task
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
task, err := h.tasks.Update(r.Context(), mustUserID(r), id, req)
|
||||
respond(w, task, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) teamSummary(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.TeamSummary(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) topCreators(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.TopCreators(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func (h *Handler) invalidAssignees(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := h.tasks.InvalidAssignees(r.Context())
|
||||
respond(w, out, http.StatusOK, err)
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, v any, status int, err error) {
|
||||
if err != nil {
|
||||
httpx.WriteError(w, httpx.StatusFromError(err), err.Error())
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, status, v)
|
||||
}
|
||||
|
||||
func mustUserID(r *http.Request) int64 {
|
||||
id, _ := httpx.UserID(r.Context())
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package httpx
|
||||
|
||||
import "context"
|
||||
|
||||
type userIDKey struct{}
|
||||
|
||||
func WithUserID(ctx context.Context, userID int64) context.Context {
|
||||
return context.WithValue(ctx, userIDKey{}, userID)
|
||||
}
|
||||
|
||||
func UserID(ctx context.Context) (int64, bool) {
|
||||
v, ok := ctx.Value(userIDKey{}).(int64)
|
||||
return v, ok
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
jwtSecret []byte
|
||||
limit int
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
requests *prometheus.CounterVec
|
||||
duration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
func NewMiddleware(jwtSecret string, rpm int, requests *prometheus.CounterVec, duration *prometheus.HistogramVec) *Middleware {
|
||||
return &Middleware{jwtSecret: []byte(jwtSecret), limit: rpm, buckets: map[string]*bucket{}, requests: requests, duration: duration}
|
||||
}
|
||||
|
||||
func (m *Middleware) Chain(next http.Handler) http.Handler {
|
||||
return m.Metrics(m.RateLimit(next))
|
||||
}
|
||||
|
||||
func (m *Middleware) Auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
WriteError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
token, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(t *jwt.Token) (any, error) {
|
||||
return m.jwtSecret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid claims")
|
||||
return
|
||||
}
|
||||
sub, err := claims.GetSubject()
|
||||
if err != nil {
|
||||
if f, ok := claims["sub"].(float64); ok {
|
||||
sub = strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
}
|
||||
id, err := strconv.ParseInt(sub, 10, 64)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusUnauthorized, "invalid subject")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithUserID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) RateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.RemoteAddr
|
||||
if id, ok := UserID(r.Context()); ok {
|
||||
key = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if !m.allow(key) {
|
||||
WriteError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) Metrics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(rec, r)
|
||||
code := strconv.Itoa(rec.status)
|
||||
m.requests.WithLabelValues(r.Method, r.URL.Path, code).Inc()
|
||||
m.duration.WithLabelValues(r.Method, r.URL.Path).Observe(time.Since(start).Seconds())
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Middleware) allow(key string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
b := m.buckets[key]
|
||||
if b == nil || now.After(b.reset) {
|
||||
m.buckets[key] = &bucket{count: 1, reset: now.Add(time.Minute)}
|
||||
return true
|
||||
}
|
||||
if b.count >= m.limit {
|
||||
return false
|
||||
}
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, status int, message string) {
|
||||
WriteJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func StatusFromError(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
return http.StatusUnauthorized
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, service.ErrNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, service.ErrBadRequest):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, service.ErrConflict):
|
||||
return http.StatusConflict
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type TaskRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTaskRepository(db *sql.DB) *TaskRepository { return &TaskRepository{db: db} }
|
||||
|
||||
func (r *TaskRepository) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks(team_id,title,description,status,assignee_id,created_by)
|
||||
VALUES(?,?,?,?,?,?)`,
|
||||
task.TeamID, task.Title, task.Description, valueOrDefault(string(task.Status), string(domain.StatusTodo)), task.AssigneeID, task.CreatedBy)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
var t domain.Task
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE id=?`, id).
|
||||
Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.Task{}, service.ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
old, err := r.Get(ctx, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE tasks SET title=?,description=?,status=?,assignee_id=? WHERE id=?`,
|
||||
task.Title, task.Description, task.Status, task.AssigneeID, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
addHistory := func(field, oldValue, newValue string) error {
|
||||
if oldValue == newValue {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO task_history(task_id,changed_by,field_name,old_value,new_value) VALUES(?,?,?,?,?)`,
|
||||
task.ID, changedBy, field, oldValue, newValue)
|
||||
return err
|
||||
}
|
||||
if err := addHistory("title", old.Title, task.Title); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("description", old.Description, task.Description); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("status", string(old.Status), string(task.Status)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("assignee_id", ptrString(old.AssigneeID), ptrString(task.AssigneeID)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
return r.Get(ctx, task.ID)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
args := []any{filter.TeamID}
|
||||
where := []string{"team_id=?"}
|
||||
if filter.Status != "" {
|
||||
where = append(where, "status=?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.AssigneeID != nil {
|
||||
where = append(where, "assignee_id=?")
|
||||
args = append(args, *filter.AssigneeID)
|
||||
}
|
||||
limit, offset := filter.PageSize, (filter.Page-1)*filter.PageSize
|
||||
args = append(args, limit, offset)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?`, strings.Join(where, " AND "))
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id,task_id,changed_by,field_name,old_value,new_value,created_at
|
||||
FROM task_history WHERE task_id=? ORDER BY created_at DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TaskHistory
|
||||
for rows.Next() {
|
||||
var h domain.TaskHistory
|
||||
if err := rows.Scan(&h.ID, &h.TaskID, &h.ChangedBy, &h.FieldName, &h.OldValue, &h.NewValue, &h.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.name, COUNT(DISTINCT tm.user_id) AS members_count,
|
||||
COUNT(DISTINCT CASE WHEN ta.status='done' AND ta.updated_at >= NOW() - INTERVAL 7 DAY THEN ta.id END) AS done_last_7_days
|
||||
FROM teams t
|
||||
LEFT JOIN team_members tm ON tm.team_id=t.id
|
||||
LEFT JOIN tasks ta ON ta.team_id=t.id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TeamSummary
|
||||
for rows.Next() {
|
||||
var s domain.TeamSummary
|
||||
if err := rows.Scan(&s.TeamID, &s.TeamName, &s.MembersCount, &s.DoneLast7Days); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT t.team_id, te.name AS team_name, t.created_by AS user_id, u.name AS user_name,
|
||||
COUNT(*) AS tasks_created,
|
||||
DENSE_RANK() OVER (PARTITION BY t.team_id ORDER BY COUNT(*) DESC) AS user_rank
|
||||
FROM tasks t
|
||||
JOIN teams te ON te.id=t.team_id
|
||||
JOIN users u ON u.id=t.created_by
|
||||
WHERE t.created_at >= NOW() - INTERVAL 1 MONTH
|
||||
GROUP BY t.team_id, te.name, t.created_by, u.name
|
||||
)
|
||||
SELECT team_id,team_name,user_id,user_name,tasks_created,user_rank
|
||||
FROM ranked WHERE user_rank <= 3 ORDER BY team_id,user_rank`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TopCreator
|
||||
for rows.Next() {
|
||||
var c domain.TopCreator
|
||||
if err := rows.Scan(&c.TeamID, &c.TeamName, &c.UserID, &c.UserName, &c.TasksCreated, &c.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT ta.id,ta.team_id,ta.title,ta.description,ta.status,ta.assignee_id,ta.created_by,ta.created_at,ta.updated_at
|
||||
FROM tasks ta
|
||||
LEFT JOIN team_members tm ON tm.team_id=ta.team_id AND tm.user_id=ta.assignee_id
|
||||
WHERE ta.assignee_id IS NOT NULL AND tm.user_id IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func valueOrDefault(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ptrString(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(*v)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build integration
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
func TestTaskRepositoryReportsWithMySQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dsn := testDSN(ctx, t)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
migrate(ctx, t, db)
|
||||
|
||||
repo := NewTaskRepository(db)
|
||||
if _, err := repo.TeamSummary(context.Background()); err != nil {
|
||||
t.Fatalf("team summary query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.TopCreators(context.Background()); err != nil {
|
||||
t.Fatalf("top creators query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.InvalidAssignees(context.Background()); err != nil {
|
||||
t.Fatalf("invalid assignees query failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testDSN(ctx context.Context, t *testing.T) string {
|
||||
t.Helper()
|
||||
if dsn := os.Getenv("TEST_MYSQL_DSN"); dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
container, err := tcmysql.Run(ctx,
|
||||
"mysql:8.4",
|
||||
tcmysql.WithDatabase("task_service_test"),
|
||||
tcmysql.WithUsername("app"),
|
||||
tcmysql.WithPassword("app"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("3306/tcp").WithStartupTimeout(2*time.Minute)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("start mysql container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
})
|
||||
dsn, err := container.ConnectionString(ctx, "parseTime=true", "multiStatements=true")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql dsn: %v", err)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "001_init.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range strings.Split(string(data), ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("migration failed: %v\nsql: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTeamRepository(db *sql.DB) *TeamRepository { return &TeamRepository{db: db} }
|
||||
|
||||
func (r *TeamRepository) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO teams(name,created_by) VALUES(?,?)`, name, createdBy)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return domain.Team{ID: id, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *TeamRepository) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO team_members(user_id,team_id,role) VALUES(?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role)`, userID, teamID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TeamRepository) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id,t.name,t.created_by,tm.role,t.created_at
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id=t.id
|
||||
WHERE tm.user_id=?
|
||||
ORDER BY t.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Team
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedBy, &t.Role, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TeamRepository) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
var role domain.Role
|
||||
err := r.db.QueryRowContext(ctx, `SELECT role FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type UserRepository struct{ db *sql.DB }
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} }
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO users(email,password_hash,name) VALUES(?,?,?)`, email, passwordHash, name)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE email=?`, email))
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE id=?`, id))
|
||||
}
|
||||
|
||||
func (r *UserRepository) scan(row *sql.Row) (domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.User{}, service.ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
users UserRepository
|
||||
jwtSecret []byte
|
||||
jwtTTL time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(users UserRepository, jwtSecret string, jwtTTL time.Duration) *AuthService {
|
||||
return &AuthService{users: users, jwtSecret: []byte(jwtSecret), jwtTTL: jwtTTL}
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, email, password, name string) (int64, error) {
|
||||
if email == "" || password == "" || name == "" {
|
||||
return 0, ErrBadRequest
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, email, string(hash), name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
claims := jwt.MapClaims{
|
||||
"sub": u.ID,
|
||||
"exp": time.Now().Add(s.jwtTTL).Unix(),
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, email, passwordHash, name string) (domain.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (domain.User, error)
|
||||
FindByID(ctx context.Context, id int64) (domain.User, error)
|
||||
}
|
||||
|
||||
type TeamRepository interface {
|
||||
Create(ctx context.Context, name string, createdBy int64) (domain.Team, error)
|
||||
AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error
|
||||
ListByUser(ctx context.Context, userID int64) ([]domain.Team, error)
|
||||
MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error)
|
||||
}
|
||||
|
||||
type TaskRepository interface {
|
||||
Create(ctx context.Context, task domain.Task) (domain.Task, error)
|
||||
Get(ctx context.Context, id int64) (domain.Task, error)
|
||||
Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error)
|
||||
List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error)
|
||||
History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error)
|
||||
TeamSummary(ctx context.Context) ([]domain.TeamSummary, error)
|
||||
TopCreators(ctx context.Context) ([]domain.TopCreator, error)
|
||||
InvalidAssignees(ctx context.Context) ([]domain.Task, error)
|
||||
}
|
||||
|
||||
type TaskCache interface {
|
||||
GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error)
|
||||
SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error
|
||||
DeleteTeam(ctx context.Context, teamID int64) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendInvite(ctx context.Context, email, teamName string) error
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
tasks TaskRepository
|
||||
teams TeamRepository
|
||||
cache TaskCache
|
||||
}
|
||||
|
||||
func NewTaskService(tasks TaskRepository, teams TeamRepository, cache TaskCache) *TaskService {
|
||||
return &TaskService{tasks: tasks, teams: teams, cache: cache}
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(ctx context.Context, userID int64, task domain.Task) (domain.Task, error) {
|
||||
if task.TeamID == 0 || task.Title == "" {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if task.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, *task.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
}
|
||||
task.CreatedBy = userID
|
||||
created, err := s.tasks.Create(ctx, task)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, task.TeamID)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *TaskService) List(ctx context.Context, userID int64, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
if filter.TeamID == 0 {
|
||||
return nil, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, filter.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 || filter.PageSize > 100 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
if cached, ok, err := s.cache.GetTasks(ctx, filter); err == nil && ok {
|
||||
return cached, nil
|
||||
}
|
||||
tasks, err := s.tasks.List(ctx, filter)
|
||||
if err == nil {
|
||||
_ = s.cache.SetTasks(ctx, filter, tasks)
|
||||
}
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *TaskService) Update(ctx context.Context, userID, taskID int64, patch domain.Task) (domain.Task, error) {
|
||||
current, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
role, ok, err := s.teams.MemberRole(ctx, current.TeamID, userID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if !ok || (role == domain.RoleMember && current.CreatedBy != userID && (current.AssigneeID == nil || *current.AssigneeID != userID)) {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if patch.Title != "" {
|
||||
current.Title = patch.Title
|
||||
}
|
||||
if patch.Description != "" {
|
||||
current.Description = patch.Description
|
||||
}
|
||||
if patch.Status != "" {
|
||||
current.Status = patch.Status
|
||||
}
|
||||
if patch.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, current.TeamID, *patch.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
current.AssigneeID = patch.AssigneeID
|
||||
}
|
||||
updated, err := s.tasks.Update(ctx, current, userID)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, current.TeamID)
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *TaskService) History(ctx context.Context, userID, taskID int64) ([]domain.TaskHistory, error) {
|
||||
task, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.tasks.History(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *TaskService) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return s.tasks.TeamSummary(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
return s.tasks.TopCreators(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
return s.tasks.InvalidAssignees(ctx)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskServiceCreateRequiresTeamMembership(t *testing.T) {
|
||||
svc := NewTaskService(&fakeTaskRepo{}, &fakeTeamRepo{members: map[int64]map[int64]domain.Role{}}, noopCache{})
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task"})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceRejectsAssigneeOutsideTeam(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(&fakeTaskRepo{}, teams, noopCache{})
|
||||
assigneeID := int64(20)
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task", AssigneeID: &assigneeID})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("expected bad request, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceMemberCanUpdateAssignedTask(t *testing.T) {
|
||||
assigneeID := int64(10)
|
||||
repo := &fakeTaskRepo{task: domain.Task{ID: 7, TeamID: 1, Title: "old", Status: domain.StatusTodo, AssigneeID: &assigneeID, CreatedBy: 99}}
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(repo, teams, noopCache{})
|
||||
updated, err := svc.Update(context.Background(), 10, 7, domain.Task{Title: "new", Status: domain.StatusDone})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Title != "new" || updated.Status != domain.StatusDone {
|
||||
t.Fatalf("unexpected task: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskRepo struct {
|
||||
task domain.Task
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
task.ID = 1
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
if r.task.ID == 0 {
|
||||
return domain.Task{}, ErrNotFound
|
||||
}
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeTaskRepo) TopCreators(ctx context.Context) ([]domain.TopCreator, error) { return nil, nil }
|
||||
func (r *fakeTaskRepo) InvalidAssignees(ctx context.Context) ([]domain.Task, error) { return nil, nil }
|
||||
|
||||
type fakeTeamRepo struct {
|
||||
members map[int64]map[int64]domain.Role
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
return domain.Team{ID: 1, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
if r.members == nil {
|
||||
r.members = map[int64]map[int64]domain.Role{}
|
||||
}
|
||||
if r.members[teamID] == nil {
|
||||
r.members[teamID] = map[int64]domain.Role{}
|
||||
}
|
||||
r.members[teamID][userID] = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
role, ok := r.members[teamID][userID]
|
||||
return role, ok, nil
|
||||
}
|
||||
|
||||
type noopCache struct{}
|
||||
|
||||
func (noopCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (noopCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopCache) DeleteTeam(ctx context.Context, teamID int64) error { return nil }
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamService struct {
|
||||
teams TeamRepository
|
||||
users UserRepository
|
||||
email EmailSender
|
||||
}
|
||||
|
||||
func NewTeamService(teams TeamRepository, users UserRepository, email EmailSender) *TeamService {
|
||||
return &TeamService{teams: teams, users: users, email: email}
|
||||
}
|
||||
|
||||
func (s *TeamService) Create(ctx context.Context, userID int64, name string) (domain.Team, error) {
|
||||
if name == "" {
|
||||
return domain.Team{}, ErrBadRequest
|
||||
}
|
||||
t, err := s.teams.Create(ctx, name, userID)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, t.ID, userID, domain.RoleOwner); err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
t.Role = domain.RoleOwner
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) List(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return s.teams.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *TeamService) Invite(ctx context.Context, actorID, teamID int64, email string) error {
|
||||
role, ok, err := s.teams.MemberRole(ctx, teamID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || (role != domain.RoleOwner && role != domain.RoleAdmin) {
|
||||
return ErrForbidden
|
||||
}
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, teamID, u.ID, domain.RoleMember); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.email.SendInvite(ctx, email, "")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTeamServiceInviteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
err := svc.Invite(context.Background(), 10, 1, "new@example.com")
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamServiceInviteAddsMember(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleAdmin}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
if err := svc.Invite(context.Background(), 10, 1, "new@example.com"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if role, ok := teams.members[1][20]; !ok || role != domain.RoleMember {
|
||||
t.Fatalf("expected invited member, got role=%q ok=%v", role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUserRepo struct {
|
||||
user domain.User
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
return domain.User{ID: 1, Email: email, PasswordHash: passwordHash, Name: name}, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
if r.user.Email == email {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
if r.user.ID == id {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
type fakeEmail struct{}
|
||||
|
||||
func (fakeEmail) SendInvite(ctx context.Context, email, teamName string) error { return nil }
|
||||
Reference in New Issue
Block a user