This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
package auth
import "golang.org/x/crypto/bcrypt"
const bcryptCost = 12
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
return string(bytes), err
}
func VerifyPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+29
View File
@@ -0,0 +1,29 @@
package auth_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/auth"
)
func TestHashPassword_isNotPlaintext(t *testing.T) {
hash, err := auth.HashPassword("mysecret")
require.NoError(t, err)
assert.NotEqual(t, "mysecret", hash)
assert.NotEmpty(t, hash)
}
func TestVerifyPassword_correct(t *testing.T) {
hash, err := auth.HashPassword("correctpassword")
require.NoError(t, err)
assert.True(t, auth.VerifyPassword("correctpassword", hash))
}
func TestVerifyPassword_wrong(t *testing.T) {
hash, err := auth.HashPassword("correctpassword")
require.NoError(t, err)
assert.False(t, auth.VerifyPassword("wrongpassword", hash))
}
+170
View File
@@ -0,0 +1,170 @@
package auth
import (
"context"
"strings"
"time"
"github.com/gofiber/fiber/v2"
goredis "github.com/redis/go-redis/v9"
"github.com/techxcar/backend/internal/config"
redispkg "github.com/techxcar/backend/pkg/redis"
)
// LoginRepository is a subset of tenant.Repository used by auth handlers.
// Uses local types to avoid a circular import with the tenant package.
type LoginRepository interface {
GetSuperAdminByEmail(ctx context.Context, email string) (*LoginSuperAdmin, error)
GetTenantBySlug(ctx context.Context, slug string) (*LoginTenant, error)
GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*LoginUser, error)
}
type LoginSuperAdmin struct {
ID string
Email string
PasswordHash string
}
type LoginTenant struct {
ID string
Status string
}
type LoginUser struct {
ID string
Email string
PasswordHash string
Role string
Name string
Active bool
}
const refreshCookieName = "refresh_token"
const refreshCookieTTL = 30 * 24 * time.Hour
func SetRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) {
c.Cookie(&fiber.Cookie{
Name: refreshCookieName,
Value: token,
MaxAge: int(refreshCookieTTL.Seconds()),
HTTPOnly: true,
Secure: cfg.AppEnv == "production",
SameSite: "Strict",
Path: "/api/v1/auth",
})
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TenantSlug string `json:"tenant_slug"`
}
func LoginHandler(repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req loginRequest
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
return fiber.NewError(400, "email e password são obrigatórios")
}
var userID, tenantID, role string
userPayload := fiber.Map{}
if req.TenantSlug == "" {
admin, err := repo.GetSuperAdminByEmail(c.Context(), req.Email)
if err != nil || admin == nil || !VerifyPassword(req.Password, admin.PasswordHash) {
return fiber.NewError(401, "credenciais inválidas")
}
userID, tenantID, role = admin.ID, "", "super_admin"
userPayload = fiber.Map{
"id": admin.ID, "email": admin.Email,
"name": admin.Email, "role": "super_admin",
}
} else {
ten, err := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
if err != nil || ten == nil || ten.Status != "active" {
return fiber.NewError(401, "credenciais inválidas")
}
user, err := repo.GetTenantUserByEmail(c.Context(), ten.ID, req.Email)
if err != nil || user == nil || !user.Active || !VerifyPassword(req.Password, user.PasswordHash) {
return fiber.NewError(401, "credenciais inválidas")
}
userID, tenantID, role = user.ID, ten.ID, user.Role
userPayload = fiber.Map{
"id": user.ID, "email": user.Email,
"name": user.Name, "role": user.Role,
"tenantId": ten.ID,
}
}
access, err := GenerateAccessToken(userID, tenantID, role, cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao gerar token")
}
refresh, err := GenerateRefreshToken(userID, tenantID, role, cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao gerar token")
}
if rdb != nil {
ctx := c.Context()
rdb.Client.Set(ctx, "refresh:"+userID, refresh, refreshCookieTTL)
}
SetRefreshCookie(c, refresh, cfg)
return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access, "user": userPayload}, "error": nil})
}
}
func RefreshHandler(rdb *redispkg.Redis, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
refreshToken := c.Cookies(refreshCookieName)
if refreshToken == "" {
return fiber.NewError(401, "refresh token em falta")
}
claims, err := ValidateToken(refreshToken, cfg.JWTSecret)
if err != nil {
return fiber.NewError(401, "refresh token inválido ou expirado")
}
if rdb != nil {
stored, err := rdb.Client.Get(c.Context(), "refresh:"+claims.UserID).Result()
if err == goredis.Nil || stored != refreshToken {
return fiber.NewError(401, "sessão inválida")
}
}
access, err := GenerateAccessToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao renovar token")
}
newRefresh, err := GenerateRefreshToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao renovar token")
}
if rdb != nil {
rdb.Client.Set(c.Context(), "refresh:"+claims.UserID, newRefresh, refreshCookieTTL)
}
SetRefreshCookie(c, newRefresh, cfg)
return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil})
}
}
func LogoutHandler(rdb *redispkg.Redis) fiber.Handler {
return func(c *fiber.Ctx) error {
c.Cookie(&fiber.Cookie{
Name: refreshCookieName,
Value: "",
MaxAge: -1,
HTTPOnly: true,
Path: "/api/v1/auth",
})
return c.JSON(fiber.Map{"data": nil, "error": nil})
}
}
+152
View File
@@ -0,0 +1,152 @@
package auth_test
import (
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/config"
)
// stubRepo is a minimal in-memory stub for testing auth handlers without a real DB.
type stubRepo struct {
superAdmins map[string]*auth.LoginSuperAdmin
tenants map[string]*auth.LoginTenant
users map[string]*auth.LoginUser
}
func (s *stubRepo) GetSuperAdminByEmail(_ context.Context, email string) (*auth.LoginSuperAdmin, error) {
a, _ := s.superAdmins[email]
return a, nil
}
func (s *stubRepo) GetTenantBySlug(_ context.Context, slug string) (*auth.LoginTenant, error) {
t, _ := s.tenants[slug]
return t, nil
}
func (s *stubRepo) GetTenantUserByEmail(_ context.Context, tenantID, email string) (*auth.LoginUser, error) {
key := tenantID + ":" + email
u, _ := s.users[key]
return u, nil
}
func newTestApp(repo auth.LoginRepository, cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()})
}})
app.Post("/api/v1/auth/login", auth.LoginHandler(repo, nil, cfg))
app.Post("/api/v1/auth/refresh", auth.RefreshHandler(nil, cfg))
app.Post("/api/v1/auth/logout", auth.LogoutHandler(nil))
return app
}
func TestLogin_superAdmin_success(t *testing.T) {
hash, _ := auth.HashPassword("secret123")
repo := &stubRepo{
superAdmins: map[string]*auth.LoginSuperAdmin{
"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
},
}
cfg := &config.Config{JWTSecret: testSecret}
app := newTestApp(repo, cfg)
body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "secret123"})
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
data := result["data"].(map[string]any)
assert.NotEmpty(t, data["access_token"])
assert.NotNil(t, data["user"])
}
func TestLogin_wrongPassword(t *testing.T) {
hash, _ := auth.HashPassword("secret123")
repo := &stubRepo{
superAdmins: map[string]*auth.LoginSuperAdmin{
"admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash},
},
}
cfg := &config.Config{JWTSecret: testSecret}
app := newTestApp(repo, cfg)
body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrongpass"})
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
}
func TestLogin_tenantUser_success(t *testing.T) {
hash, _ := auth.HashPassword("tenantpass")
repo := &stubRepo{
tenants: map[string]*auth.LoginTenant{
"my-workshop": {ID: "11111111-1111-1111-1111-111111111111", Status: "active"},
},
users: map[string]*auth.LoginUser{
"11111111-1111-1111-1111-111111111111:user@workshop.com": {
ID: "u-1", Email: "user@workshop.com", PasswordHash: hash,
Role: "tenant_admin", Name: "User", Active: true,
},
},
}
cfg := &config.Config{JWTSecret: testSecret}
app := newTestApp(repo, cfg)
body, _ := json.Marshal(map[string]string{
"email": "user@workshop.com", "password": "tenantpass", "tenant_slug": "my-workshop",
})
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
data := result["data"].(map[string]any)
token := data["access_token"].(string)
claims, err := auth.ValidateToken(token, testSecret)
require.NoError(t, err)
assert.Equal(t, "11111111-1111-1111-1111-111111111111", claims.TenantID)
assert.Equal(t, "tenant_admin", claims.Role)
_ = time.Now()
}
func TestLogin_unknownTenant(t *testing.T) {
repo := &stubRepo{tenants: map[string]*auth.LoginTenant{}}
cfg := &config.Config{JWTSecret: testSecret}
app := newTestApp(repo, cfg)
body, _ := json.Marshal(map[string]string{
"email": "user@workshop.com", "password": "pass", "tenant_slug": "nonexistent",
})
req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
}
+54
View File
@@ -0,0 +1,54 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
TenantID string `json:"tenant_id,omitempty"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateAccessToken(userID, tenantID, role, secret string) (string, error) {
return generateToken(userID, tenantID, role, secret, 15*time.Minute)
}
func GenerateRefreshToken(userID, tenantID, role, secret string) (string, error) {
return generateToken(userID, tenantID, role, secret, 30*24*time.Hour)
}
func generateToken(userID, tenantID, role, secret string, ttl time.Duration) (string, error) {
claims := Claims{
UserID: userID,
TenantID: tenantID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("método de assinatura inesperado: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("token inválido")
}
return claims, nil
}
+51
View File
@@ -0,0 +1,51 @@
package auth_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/auth"
)
const testSecret = "test-secret-32-chars-minimum-ok!"
func TestGenerateAndValidateAccessToken(t *testing.T) {
token, err := auth.GenerateAccessToken("user-1", "tenant-1", "tenant_admin", testSecret)
require.NoError(t, err)
assert.NotEmpty(t, token)
claims, err := auth.ValidateToken(token, testSecret)
require.NoError(t, err)
assert.Equal(t, "user-1", claims.UserID)
assert.Equal(t, "tenant-1", claims.TenantID)
assert.Equal(t, "tenant_admin", claims.Role)
assert.True(t, claims.ExpiresAt.After(time.Now()))
assert.True(t, claims.ExpiresAt.Before(time.Now().Add(16*time.Minute)))
}
func TestGenerateRefreshToken_longerExpiry(t *testing.T) {
token, err := auth.GenerateRefreshToken("user-1", "", "super_admin", testSecret)
require.NoError(t, err)
claims, err := auth.ValidateToken(token, testSecret)
require.NoError(t, err)
assert.Empty(t, claims.TenantID)
assert.Equal(t, "super_admin", claims.Role)
assert.True(t, claims.ExpiresAt.After(time.Now().Add(29*24*time.Hour)))
}
func TestValidateToken_wrongSecret(t *testing.T) {
token, err := auth.GenerateAccessToken("user-1", "t-1", "manager", testSecret)
require.NoError(t, err)
_, err = auth.ValidateToken(token, "different-secret-32chars-minimumx")
assert.Error(t, err)
}
func TestValidateToken_malformed(t *testing.T) {
_, err := auth.ValidateToken("not.a.jwt", testSecret)
assert.Error(t, err)
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/techxcar/backend/pkg/database"
)
func RequireAuth(secret string) fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return fiber.NewError(401, "autenticação necessária")
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return fiber.NewError(401, "formato de autorização inválido")
}
claims, err := ValidateToken(parts[1], secret)
if err != nil {
return fiber.NewError(401, "token inválido ou expirado")
}
c.Locals("claims", claims)
return c.Next()
}
}
func RequireRole(roles ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, ok := c.Locals("claims").(*Claims)
if !ok {
return fiber.NewError(401, "autenticação necessária")
}
for _, role := range roles {
if claims.Role == role {
return c.Next()
}
}
return fiber.NewError(403, "acesso não autorizado")
}
}
// TenantMiddleware acquires a dedicated pgxpool connection per request,
// sets the tenant search_path, stores the connection in c.Locals("conn"),
// and releases the connection after the handler chain completes.
func TenantMiddleware(db *database.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, ok := c.Locals("claims").(*Claims)
if !ok || claims.TenantID == "" {
return c.Next()
}
conn, err := db.Pool.Acquire(c.Context())
if err != nil {
return fiber.NewError(500, "erro interno ao adquirir conexão")
}
schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"`
if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil {
conn.Release()
return fiber.NewError(500, "erro interno ao definir schema")
}
c.Locals("conn", conn)
err = c.Next()
conn.Release()
return err
}
}
// GetConn returns the tenant-scoped connection stored by TenantMiddleware.
func GetConn(c *fiber.Ctx) *pgxpool.Conn {
conn, _ := c.Locals("conn").(*pgxpool.Conn)
return conn
}
+117
View File
@@ -0,0 +1,117 @@
package auth_test
import (
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/auth"
)
func TestRequireAuth_missingHeader(t *testing.T) {
app := fiber.New()
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
}
func TestRequireAuth_validToken(t *testing.T) {
token, _ := auth.GenerateAccessToken("user-1", "tenant-1", "manager", testSecret)
app := fiber.New()
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
claims := c.Locals("claims").(*auth.Claims)
return c.SendString(claims.UserID)
})
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
func TestRequireAuth_invalidToken(t *testing.T) {
app := fiber.New()
app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error {
return c.SendString("ok")
})
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
}
func TestRequireRole_allowed(t *testing.T) {
token, _ := auth.GenerateAccessToken("user-1", "", "super_admin", testSecret)
app := fiber.New()
app.Get("/admin",
auth.RequireAuth(testSecret),
auth.RequireRole("super_admin"),
func(c *fiber.Ctx) error { return c.SendString("ok") },
)
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
func TestRequireRole_forbidden(t *testing.T) {
token, _ := auth.GenerateAccessToken("user-1", "t-1", "technician", testSecret)
app := fiber.New()
app.Get("/admin",
auth.RequireAuth(testSecret),
auth.RequireRole("super_admin", "tenant_admin"),
func(c *fiber.Ctx) error { return c.SendString("ok") },
)
req := httptest.NewRequest("GET", "/admin", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
}
func TestRequireRole_allowsMatchingRole(t *testing.T) {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("claims", &auth.Claims{Role: "tenant_admin"})
return c.Next()
})
app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
}
func TestRequireRole_rejectsMismatch(t *testing.T) {
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("claims", &auth.Claims{Role: "technician"})
return c.Next()
})
app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error {
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/test", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, 403, resp.StatusCode)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
goredis "github.com/redis/go-redis/v9"
)
type redisStorage struct {
client *goredis.Client
}
func NewRedisStorage(client *goredis.Client) *redisStorage {
return &redisStorage{client: client}
}
func (s *redisStorage) Get(key string) ([]byte, error) {
val, err := s.client.Get(context.Background(), key).Bytes()
if err == goredis.Nil {
return nil, nil
}
return val, err
}
func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error {
return s.client.Set(context.Background(), key, val, exp).Err()
}
func (s *redisStorage) Delete(key string) error {
return s.client.Del(context.Background(), key).Err()
}
func (s *redisStorage) Reset() error {
return s.client.FlushDB(context.Background()).Err()
}
func (s *redisStorage) Close() error {
return nil
}
func RateLimiter(storage *redisStorage) fiber.Handler {
return limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return "ratelimit:auth:" + c.IP()
},
Storage: storage,
LimitReached: func(c *fiber.Ctx) error {
return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto")
},
})
}
+23
View File
@@ -0,0 +1,23 @@
package auth
import (
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/config"
redispkg "github.com/techxcar/backend/pkg/redis"
)
func RegisterRoutes(app *fiber.App, repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) {
authGroup := app.Group("/api/v1/auth")
if rdb != nil {
storage := NewRedisStorage(rdb.Client)
rateLimiter := RateLimiter(storage)
authGroup.Post("/login", rateLimiter, LoginHandler(repo, rdb, cfg))
authGroup.Post("/refresh", rateLimiter, RefreshHandler(rdb, cfg))
} else {
authGroup.Post("/login", LoginHandler(repo, rdb, cfg))
authGroup.Post("/refresh", RefreshHandler(rdb, cfg))
}
authGroup.Post("/logout", LogoutHandler(rdb))
}
+108
View File
@@ -0,0 +1,108 @@
package catalog
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
ro := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/catalog", append(ro, listItemsH())...)
app.Post("/api/v1/catalog", append(write, createItemH())...)
app.Put("/api/v1/catalog/:id", append(write, updateItemH())...)
app.Delete("/api/v1/catalog/:id", append(write, deleteItemH())...)
}
func listItemsH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListItems(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao listar catálogo")
}
if list == nil {
list = []*CatalogItem{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type itemBody struct {
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
Unit string `json:"unit"`
BasePrice float64 `json:"base_price"`
Active bool `json:"active"`
}
func createItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b itemBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" {
return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios")
}
validUnits := map[string]bool{"un": true, "hora": true, "litro": true, "kg": true}
if !validUnits[b.Unit] {
return fiber.NewError(400, "unidade inválida (un, hora, litro, kg)")
}
conn := auth.GetConn(c)
item, err := CreateItem(c.Context(), conn, b.Code, b.Name, b.Category, b.Unit, b.BasePrice)
if err != nil {
return fiber.NewError(500, "erro ao criar item")
}
return c.Status(201).JSON(fiber.Map{"data": item, "error": nil})
}
}
func updateItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b itemBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" {
return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios")
}
validUnits := map[string]bool{"un": true, "hora": true, "litro": true, "kg": true}
if !validUnits[b.Unit] {
return fiber.NewError(400, "unidade inválida (un, hora, litro, kg)")
}
conn := auth.GetConn(c)
item, err := UpdateItem(c.Context(), conn, c.Params("id"), b.Code, b.Name, b.Category, b.Unit, b.BasePrice, b.Active)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "item não encontrado")
}
return fiber.NewError(500, "erro ao actualizar item")
}
return c.JSON(fiber.Map{"data": item, "error": nil})
}
}
func deleteItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := DeleteItem(c.Context(), conn, c.Params("id")); err != nil {
return fiber.NewError(500, "erro ao eliminar item")
}
return c.SendStatus(204)
}
}
+67
View File
@@ -0,0 +1,67 @@
package catalog
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type CatalogItem struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Category string `json:"category"`
Unit string `json:"unit"`
BasePrice float64 `json:"base_price"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListItems(ctx context.Context, conn *pgxpool.Conn) ([]*CatalogItem, error) {
rows, err := conn.Query(ctx, `
SELECT id, code, name, category, unit, base_price, active, created_at, updated_at
FROM catalog_items ORDER BY category, name`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*CatalogItem
for rows.Next() {
var i CatalogItem
if err := rows.Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit,
&i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &i)
}
return list, rows.Err()
}
func CreateItem(ctx context.Context, conn *pgxpool.Conn, code, name, category, unit string, basePrice float64) (*CatalogItem, error) {
var i CatalogItem
err := conn.QueryRow(ctx, `
INSERT INTO catalog_items (code, name, category, unit, base_price)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`,
code, name, category, unit, basePrice).
Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt)
return &i, err
}
func UpdateItem(ctx context.Context, conn *pgxpool.Conn, id, code, name, category, unit string, basePrice float64, active bool) (*CatalogItem, error) {
var i CatalogItem
err := conn.QueryRow(ctx, `
UPDATE catalog_items SET code=$2, name=$3, category=$4, unit=$5, base_price=$6, active=$7, updated_at=NOW()
WHERE id=$1
RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`,
id, code, name, category, unit, basePrice, active).
Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt)
return &i, err
}
func DeleteItem(ctx context.Context, conn *pgxpool.Conn, id string) error {
_, err := conn.Exec(ctx, `DELETE FROM catalog_items WHERE id = $1`, id)
return err
}
@@ -0,0 +1,50 @@
package catalog_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/catalog"
)
func getTestConn(t *testing.T) *pgxpool.Conn {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
require.NoError(t, err)
t.Cleanup(func() { pool.Close() })
conn, err := pool.Acquire(context.Background())
require.NoError(t, err)
t.Cleanup(func() { conn.Release() })
_, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public")
require.NoError(t, err)
return conn
}
func TestCatalogCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
item, err := catalog.CreateItem(ctx, conn, "MO-5W40", "Óleo Motor 5W40", "lubrificantes", "litro", 12.50)
require.NoError(t, err)
assert.NotEmpty(t, item.ID)
assert.Equal(t, "MO-5W40", item.Code)
list, err := catalog.ListItems(ctx, conn)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 1)
updated, err := catalog.UpdateItem(ctx, conn, item.ID, "MO-5W40", "Óleo Motor 5W40 Sintético", "lubrificantes", "litro", 15.00, true)
require.NoError(t, err)
assert.Equal(t, 15.00, updated.BasePrice)
err = catalog.DeleteItem(ctx, conn, item.ID)
require.NoError(t, err)
}
+183
View File
@@ -0,0 +1,183 @@
package client
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
rw := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/clients", append(rw, listClientsH())...)
app.Post("/api/v1/clients", append(write, createClientH())...)
app.Get("/api/v1/clients/:id", append(rw, getClientH())...)
app.Put("/api/v1/clients/:id", append(write, updateClientH())...)
app.Delete("/api/v1/clients/:id", append(write, deleteClientH())...)
app.Get("/api/v1/clients/:id/vehicles", append(rw, listVehiclesH())...)
app.Post("/api/v1/clients/:id/vehicles", append(write, createVehicleH())...)
app.Put("/api/v1/vehicles/:id", append(write, updateVehicleH())...)
}
func listClientsH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListClients(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao listar clientes")
}
if list == nil {
list = []*Client{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
func getClientH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
cl, err := GetClient(c.Context(), conn, c.Params("id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "cliente não encontrado")
}
return fiber.NewError(500, "erro ao obter cliente")
}
return c.JSON(fiber.Map{"data": cl, "error": nil})
}
}
type clientBody struct {
Name string `json:"name"`
NIF string `json:"nif"`
Phone string `json:"phone"`
Email string `json:"email"`
Address string `json:"address"`
Notes string `json:"notes"`
}
func createClientH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b clientBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Name == "" {
return fiber.NewError(400, "nome é obrigatório")
}
conn := auth.GetConn(c)
cl, err := CreateClient(c.Context(), conn, b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes)
if err != nil {
return fiber.NewError(500, "erro ao criar cliente")
}
return c.Status(201).JSON(fiber.Map{"data": cl, "error": nil})
}
}
func updateClientH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b clientBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Name == "" {
return fiber.NewError(400, "nome é obrigatório")
}
conn := auth.GetConn(c)
cl, err := UpdateClient(c.Context(), conn, c.Params("id"), b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "cliente não encontrado")
}
return fiber.NewError(500, "erro ao actualizar cliente")
}
return c.JSON(fiber.Map{"data": cl, "error": nil})
}
}
func deleteClientH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := DeleteClient(c.Context(), conn, c.Params("id")); err != nil {
return fiber.NewError(500, "erro ao eliminar cliente")
}
return c.SendStatus(204)
}
}
func listVehiclesH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListVehiclesByClient(c.Context(), conn, c.Params("id"))
if err != nil {
return fiber.NewError(500, "erro ao listar veículos")
}
if list == nil {
list = []*Vehicle{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type vehicleBody struct {
Plate string `json:"plate"`
Brand string `json:"brand"`
Model string `json:"model"`
Year int `json:"year"`
VIN string `json:"vin"`
FuelType string `json:"fuel_type"`
Mileage int `json:"mileage"`
Notes string `json:"notes"`
}
func createVehicleH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b vehicleBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Plate == "" {
return fiber.NewError(400, "matrícula é obrigatória")
}
conn := auth.GetConn(c)
v, err := CreateVehicle(c.Context(), conn, c.Params("id"), b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.FuelType, b.Notes, b.Mileage)
if err != nil {
return fiber.NewError(500, "erro ao criar veículo")
}
return c.Status(201).JSON(fiber.Map{"data": v, "error": nil})
}
}
func updateVehicleH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b vehicleBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Plate == "" {
return fiber.NewError(400, "matrícula é obrigatória")
}
conn := auth.GetConn(c)
v, err := UpdateVehicle(c.Context(), conn, c.Params("id"), b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.FuelType, b.Notes, b.Mileage)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "veículo não encontrado")
}
return fiber.NewError(500, "erro ao actualizar veículo")
}
return c.JSON(fiber.Map{"data": v, "error": nil})
}
}
+166
View File
@@ -0,0 +1,166 @@
package client
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Client struct {
ID string `json:"id"`
Name string `json:"name"`
NIF string `json:"nif"`
Phone string `json:"phone"`
Email string `json:"email"`
Address string `json:"address"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Vehicle struct {
ID string `json:"id"`
ClientID *string `json:"client_id"`
Plate string `json:"plate"`
Brand string `json:"brand"`
Model string `json:"model"`
Year *int `json:"year"`
VIN string `json:"vin"`
Mileage *int `json:"mileage"`
FuelType string `json:"fuel_type"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListClients(ctx context.Context, conn *pgxpool.Conn) ([]*Client, error) {
rows, err := conn.Query(ctx, `
SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''),
COALESCE(address,''), COALESCE(notes,''), created_at, updated_at
FROM clients ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*Client
for rows.Next() {
var c Client
if err := rows.Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email,
&c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &c)
}
return list, rows.Err()
}
func GetClient(ctx context.Context, conn *pgxpool.Conn, id string) (*Client, error) {
var c Client
err := conn.QueryRow(ctx, `
SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''),
COALESCE(address,''), COALESCE(notes,''), created_at, updated_at
FROM clients WHERE id = $1`, id).
Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email,
&c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func CreateClient(ctx context.Context, conn *pgxpool.Conn, name, nif, phone, email, address, notes string) (*Client, error) {
var c Client
err := conn.QueryRow(ctx, `
INSERT INTO clients (name, nif, phone, email, address, notes)
VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,''))
RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''),
COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`,
name, nif, phone, email, address, notes).
Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email,
&c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt)
return &c, err
}
func UpdateClient(ctx context.Context, conn *pgxpool.Conn, id, name, nif, phone, email, address, notes string) (*Client, error) {
var c Client
err := conn.QueryRow(ctx, `
UPDATE clients SET name=$2, nif=NULLIF($3,''), phone=NULLIF($4,''), email=NULLIF($5,''),
address=NULLIF($6,''), notes=NULLIF($7,''), updated_at=NOW()
WHERE id=$1
RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''),
COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`,
id, name, nif, phone, email, address, notes).
Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email,
&c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt)
return &c, err
}
func DeleteClient(ctx context.Context, conn *pgxpool.Conn, id string) error {
_, err := conn.Exec(ctx, `DELETE FROM clients WHERE id = $1`, id)
return err
}
func ListVehiclesByClient(ctx context.Context, conn *pgxpool.Conn, clientID string) ([]*Vehicle, error) {
rows, err := conn.Query(ctx, `
SELECT id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage,
COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at
FROM vehicles WHERE client_id = $1 ORDER BY plate`, clientID)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*Vehicle
for rows.Next() {
var v Vehicle
if err := rows.Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model,
&v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &v)
}
return list, rows.Err()
}
func CreateVehicle(ctx context.Context, conn *pgxpool.Conn, clientID, plate, brand, model string, year int, vin, fuelType, notes string, mileage int) (*Vehicle, error) {
var v Vehicle
var yearPtr *int
if year != 0 {
yearPtr = &year
}
var mileagePtr *int
if mileage != 0 {
mileagePtr = &mileage
}
err := conn.QueryRow(ctx, `
INSERT INTO vehicles (client_id, plate, brand, model, year, vin, fuel_type, mileage, notes)
VALUES (NULLIF($1,'')::uuid, $2, $3, $4, $5, NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,''))
RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage,
COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at`,
clientID, plate, brand, model, yearPtr, vin, fuelType, mileagePtr, notes).
Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model,
&v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt)
return &v, err
}
func UpdateVehicle(ctx context.Context, conn *pgxpool.Conn, id, plate, brand, model string, year int, vin, fuelType, notes string, mileage int) (*Vehicle, error) {
var v Vehicle
var yearPtr *int
if year != 0 {
yearPtr = &year
}
var mileagePtr *int
if mileage != 0 {
mileagePtr = &mileage
}
err := conn.QueryRow(ctx, `
UPDATE vehicles SET plate=$2, brand=$3, model=$4,
year=$5, vin=NULLIF($6,''), fuel_type=NULLIF($7,''), mileage=$8, notes=NULLIF($9,''), updated_at=NOW()
WHERE id=$1
RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage,
COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at`,
id, plate, brand, model, yearPtr, vin, fuelType, mileagePtr, notes).
Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model,
&v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt)
return &v, err
}
@@ -0,0 +1,78 @@
package client_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/client"
)
func getTestConn(t *testing.T) *pgxpool.Conn {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
require.NoError(t, err)
t.Cleanup(func() { pool.Close() })
conn, err := pool.Acquire(context.Background())
require.NoError(t, err)
t.Cleanup(func() { conn.Release() })
_, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public")
require.NoError(t, err)
return conn
}
func TestClientCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
c, err := client.CreateClient(ctx, conn, "João Silva", "123456789", "912345678", "joao@example.com", "Rua A", "")
require.NoError(t, err)
assert.NotEmpty(t, c.ID)
assert.Equal(t, "João Silva", c.Name)
list, err := client.ListClients(ctx, conn)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 1)
got, err := client.GetClient(ctx, conn, c.ID)
require.NoError(t, err)
assert.Equal(t, c.ID, got.ID)
updated, err := client.UpdateClient(ctx, conn, c.ID, "João Santos", "987654321", "921000000", "joao2@example.com", "Rua B", "nota")
require.NoError(t, err)
assert.Equal(t, "João Santos", updated.Name)
err = client.DeleteClient(ctx, conn, c.ID)
require.NoError(t, err)
}
func TestVehicleCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
c, err := client.CreateClient(ctx, conn, "Test Client", "", "", "", "", "")
require.NoError(t, err)
v, err := client.CreateVehicle(ctx, conn, c.ID, "AA-00-AA", "Toyota", "Yaris", 2020, "", 50000, "")
require.NoError(t, err)
assert.NotEmpty(t, v.ID)
assert.Equal(t, "AA-00-AA", v.Plate)
list, err := client.ListVehiclesByClient(ctx, conn, c.ID)
require.NoError(t, err)
assert.Len(t, list, 1)
updated, err := client.UpdateVehicle(ctx, conn, v.ID, "BB-11-BB", "Toyota", "Yaris", 2021, "", 60000, "nota")
require.NoError(t, err)
assert.Equal(t, "BB-11-BB", updated.Plate)
}
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"errors"
"os"
)
type Config struct {
DatabaseURL string
RedisURL string
JWTSecret string
Port string
AppEnv string
InitialAdminEmail string
InitialAdminPassword string
}
func Load() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, errors.New("DATABASE_URL is required")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, errors.New("JWT_SECRET is required")
}
redisURL := os.Getenv("REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
appEnv := os.Getenv("APP_ENV")
if appEnv == "" {
appEnv = "development"
}
return &Config{
DatabaseURL: dbURL,
RedisURL: redisURL,
JWTSecret: jwtSecret,
Port: port,
AppEnv: appEnv,
InitialAdminEmail: os.Getenv("INITIAL_ADMIN_EMAIL"),
InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"),
}, nil
}
+65
View File
@@ -0,0 +1,65 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/config"
)
func TestLoad_defaults(t *testing.T) {
os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test")
os.Setenv("REDIS_URL", "redis://localhost:6379")
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!")
defer func() {
os.Unsetenv("DATABASE_URL")
os.Unsetenv("REDIS_URL")
os.Unsetenv("JWT_SECRET")
}()
cfg, err := config.Load()
require.NoError(t, err)
assert.Equal(t, "8080", cfg.Port)
assert.Equal(t, "development", cfg.AppEnv)
assert.Equal(t, "postgres://test:test@localhost/test", cfg.DatabaseURL)
assert.Equal(t, "redis://localhost:6379", cfg.RedisURL)
}
func TestLoad_missingDatabaseURL(t *testing.T) {
os.Unsetenv("DATABASE_URL")
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!")
defer os.Unsetenv("JWT_SECRET")
_, err := config.Load()
assert.ErrorContains(t, err, "DATABASE_URL")
}
func TestLoad_missingJWTSecret(t *testing.T) {
os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test")
os.Unsetenv("JWT_SECRET")
defer os.Unsetenv("DATABASE_URL")
_, err := config.Load()
assert.ErrorContains(t, err, "JWT_SECRET")
}
func TestLoad_customPort(t *testing.T) {
os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test")
os.Setenv("REDIS_URL", "redis://localhost:6379")
os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!")
os.Setenv("PORT", "9090")
defer func() {
os.Unsetenv("DATABASE_URL")
os.Unsetenv("REDIS_URL")
os.Unsetenv("JWT_SECRET")
os.Unsetenv("PORT")
}()
cfg, err := config.Load()
require.NoError(t, err)
assert.Equal(t, "9090", cfg.Port)
}
+104
View File
@@ -0,0 +1,104 @@
package expense
import (
"errors"
"time"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
var allowedTypes = map[string]bool{
"fuel": true,
"parts": true,
"tools": true,
"other": true,
}
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
ro := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/expenses", append(ro, listExpensesH())...)
app.Post("/api/v1/expenses", append(write, createExpenseH())...)
app.Delete("/api/v1/expenses/:id", append(write, deleteExpenseH())...)
}
func listExpensesH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListExpenses(c.Context(), conn, c.Query("type"))
if err != nil {
return fiber.NewError(500, "erro ao listar despesas")
}
if list == nil {
list = []*Expense{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type expenseBody struct {
VehicleID string `json:"vehicle_id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
Date string `json:"date"` // YYYY-MM-DD
}
func createExpenseH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b expenseBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if !allowedTypes[b.Type] {
return fiber.NewError(400, "tipo inválido: fuel, parts, tools, other")
}
if b.Amount <= 0 {
return fiber.NewError(400, "valor deve ser positivo")
}
if b.Date == "" {
return fiber.NewError(400, "data obrigatória")
}
date, err := time.Parse("2006-01-02", b.Date)
if err != nil {
return fiber.NewError(400, "data inválida (formato: YYYY-MM-DD)")
}
var vehicleID *string
if b.VehicleID != "" {
vehicleID = &b.VehicleID
}
conn := auth.GetConn(c)
e, err := CreateExpense(c.Context(), conn, vehicleID, b.Type, b.Description, b.Amount, date)
if err != nil {
return fiber.NewError(500, "erro ao registar despesa")
}
return c.Status(201).JSON(fiber.Map{"data": e, "error": nil})
}
}
func deleteExpenseH() fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Params("id")
conn := auth.GetConn(c)
err := DeleteExpense(c.Context(), conn, id)
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "despesa não encontrada")
}
if err != nil {
return fiber.NewError(500, "erro ao eliminar despesa")
}
return c.SendStatus(204)
}
}
+69
View File
@@ -0,0 +1,69 @@
package expense
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Expense struct {
ID string `json:"id"`
VehicleID *string `json:"vehicle_id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
Date time.Time `json:"date"`
CreatedAt time.Time `json:"created_at"`
}
func ListExpenses(ctx context.Context, conn *pgxpool.Conn, typeFilter string) ([]*Expense, error) {
q := `SELECT id, vehicle_id, type, amount, COALESCE(description,''), date, created_at
FROM expenses`
args := []any{}
if typeFilter != "" {
q += " WHERE type = $1"
args = append(args, typeFilter)
}
q += " ORDER BY date DESC, created_at DESC"
rows, err := conn.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("expense: list: %w", err)
}
defer rows.Close()
var list []*Expense
for rows.Next() {
var e Expense
if err := rows.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("expense: scan: %w", err)
}
list = append(list, &e)
}
return list, rows.Err()
}
func CreateExpense(ctx context.Context, conn *pgxpool.Conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error) {
row := conn.QueryRow(ctx,
`INSERT INTO expenses (vehicle_id, type, amount, description, date)
VALUES ($1, $2, $3, NULLIF($4,''), $5)
RETURNING id, vehicle_id, type, amount, COALESCE(description,''), date, created_at`,
vehicleID, expType, amount, description, date)
var e Expense
if err := row.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("expense: create: %w", err)
}
return &e, nil
}
func DeleteExpense(ctx context.Context, conn *pgxpool.Conn, id string) error {
tag, err := conn.Exec(ctx, `DELETE FROM expenses WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("expense: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
+186
View File
@@ -0,0 +1,186 @@
package invoice
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/workorder"
"github.com/techxcar/backend/pkg/database"
"github.com/techxcar/backend/pkg/pdf"
)
const storageRoot = "/app/storage"
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
ro := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/invoices", append(ro, listInvoicesH())...)
app.Post("/api/v1/invoices", append(write, createInvoiceH())...)
app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...)
}
func listInvoicesH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListInvoices(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao listar faturas")
}
if list == nil {
list = []*Invoice{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type createBody struct {
WorkOrderID string `json:"work_order_id"`
Type string `json:"type"` // "quote" or "invoice"
}
func tenantDir(c *fiber.Ctx) string {
claims, _ := c.Locals("claims").(*auth.Claims)
if claims == nil || claims.TenantID == "" {
return "unknown"
}
return "tenant_" + strings.ReplaceAll(claims.TenantID, "-", "_")
}
func createInvoiceH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b createBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if b.WorkOrderID == "" {
return fiber.NewError(400, "work_order_id obrigatório")
}
if b.Type != "quote" && b.Type != "invoice" {
return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'")
}
conn := auth.GetConn(c)
detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID)
if err != nil {
return fiber.NewError(500, "erro ao obter ordem de trabalho")
}
if detail == nil {
return fiber.NewError(404, "ordem de trabalho não encontrada")
}
sett, err := settings.GetSettings(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao obter definições")
}
var clientName, clientNIF, vehiclePlate string
if detail.ClientID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`,
*detail.ClientID).Scan(&clientName, &clientNIF)
}
if detail.VehicleID != nil {
_ = conn.QueryRow(c.Context(),
`SELECT plate FROM vehicles WHERE id = $1`,
*detail.VehicleID).Scan(&vehiclePlate)
}
inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type)
if err != nil {
return fiber.NewError(500, "erro ao criar fatura")
}
docType := "Orcamento"
prefix := "ORC"
if b.Type == "invoice" {
docType = "Fatura"
prefix = "FAT"
}
outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID))
meta := pdf.DocMeta{
CompanyName: sett["company_name"],
CompanyNIF: sett["company_nif"],
CompanyAddress: sett["company_address"],
CompanyIBAN: sett["company_iban"],
CompanyPhone: sett["company_phone"],
CompanyEmail: sett["company_email"],
DocType: docType,
DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number),
IssuedAt: inv.IssuedAt.Format("02/01/2006"),
ClientName: clientName,
ClientNIF: clientNIF,
VehiclePlate: vehiclePlate,
}
lineItems := make([]pdf.LineItem, len(detail.Items))
for i, item := range detail.Items {
lineItems[i] = pdf.LineItem{
Description: item.Description,
Qty: item.Qty,
UnitPrice: item.UnitPrice,
DiscountPct: item.DiscountPct,
Total: item.Total,
}
}
var staffTotal float64
for _, sh := range detail.StaffHours {
staffTotal += sh.Total
}
if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil {
return fiber.NewError(500, "erro ao gerar PDF")
}
if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil {
return fiber.NewError(500, "erro ao registar caminho do PDF")
}
inv.PDFPath = outPath
if b.Type == "invoice" {
if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil {
return fiber.NewError(500, "erro ao atualizar estado da ordem")
}
}
return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil})
}
}
func downloadPDFH() fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Params("id")
conn := auth.GetConn(c)
inv, err := GetInvoice(c.Context(), conn, id)
if err != nil {
return fiber.NewError(500, "erro interno")
}
if inv == nil {
return fiber.NewError(404, "fatura não encontrada")
}
if inv.PDFPath == "" {
return fiber.NewError(404, "PDF não disponível")
}
if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) {
return fiber.NewError(404, "ficheiro PDF não encontrado")
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number))
return c.SendFile(inv.PDFPath)
}
}
+78
View File
@@ -0,0 +1,78 @@
package invoice
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Invoice struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
Type string `json:"type"`
Number int `json:"number"`
PDFPath string `json:"pdf_path"`
IssuedAt time.Time `json:"issued_at"`
CreatedAt time.Time `json:"created_at"`
}
func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) {
rows, err := conn.Query(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
FROM invoices ORDER BY issued_at DESC`)
if err != nil {
return nil, fmt.Errorf("invoice: list: %w", err)
}
defer rows.Close()
var list []*Invoice
for rows.Next() {
var inv Invoice
if err := rows.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil {
return nil, fmt.Errorf("invoice: scan: %w", err)
}
list = append(list, &inv)
}
return list, rows.Err()
}
func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) {
row := conn.QueryRow(ctx,
`SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at
FROM invoices WHERE id = $1`, id)
var inv Invoice
err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("invoice: get: %w", err)
}
return &inv, nil
}
func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string) (*Invoice, error) {
row := conn.QueryRow(ctx,
`INSERT INTO invoices (work_order_id, type, issued_at)
VALUES ($1, $2, NOW())
RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`,
woID, docType)
var inv Invoice
if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number,
&inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil {
return nil, fmt.Errorf("invoice: create: %w", err)
}
return &inv, nil
}
func SetPDFPath(ctx context.Context, conn *pgxpool.Conn, id, path string) error {
_, err := conn.Exec(ctx, `UPDATE invoices SET pdf_path = $2 WHERE id = $1`, id, path)
if err != nil {
return fmt.Errorf("invoice: set pdf path: %w", err)
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package server
import "github.com/gofiber/fiber/v2"
const appVersion = "0.1.0"
func RegisterHealthRoutes(app *fiber.App) {
app.Get("/api/v1/health", handleHealth)
}
func handleHealth(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"version": appVersion,
})
}
+40
View File
@@ -0,0 +1,40 @@
package server_test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/server"
)
func TestHealthEndpoint_returnsOK(t *testing.T) {
app := fiber.New()
server.RegisterHealthRoutes(app)
req := httptest.NewRequest("GET", "/api/v1/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "ok", body["status"])
assert.NotEmpty(t, body["version"])
}
func TestHealthEndpoint_wrongMethod(t *testing.T) {
app := fiber.New()
server.RegisterHealthRoutes(app)
req := httptest.NewRequest("POST", "/api/v1/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 405, resp.StatusCode)
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/catalog"
"github.com/techxcar/backend/internal/client"
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/internal/expense"
"github.com/techxcar/backend/internal/invoice"
"github.com/techxcar/backend/internal/settings"
"github.com/techxcar/backend/internal/staff"
"github.com/techxcar/backend/internal/tenant"
"github.com/techxcar/backend/internal/workorder"
"github.com/techxcar/backend/pkg/database"
redispkg "github.com/techxcar/backend/pkg/redis"
)
type Deps struct {
Config *config.Config
DB *database.DB
Redis *redispkg.Redis
}
func New(deps Deps) *fiber.App {
app := fiber.New(fiber.Config{
AppName: "TechXCar API",
ErrorHandler: errorHandler,
})
app.Use(recover.New())
app.Use(logger.New())
app.Use(helmet.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:3000,http://localhost:5173",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
AllowCredentials: true,
}))
RegisterHealthRoutes(app)
if deps.DB != nil && deps.Redis != nil && deps.Config != nil {
repo := tenant.NewRepository(deps.DB)
auth.RegisterRoutes(app, tenant.LoginAdapter(repo), deps.Redis, deps.Config)
tenant.RegisterRoutes(app, repo, deps.DB, deps.Config)
client.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
catalog.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
workorder.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret)
}
return app
}
func errorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{
"data": nil,
"error": err.Error(),
})
}
+57
View File
@@ -0,0 +1,57 @@
package settings
import (
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
read := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
admin := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/settings", append(read, getSettingsH())...)
app.Put("/api/v1/settings", append(admin, updateSettingsH())...)
}
func getSettingsH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
s, err := GetSettings(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao obter definições")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func updateSettingsH() fiber.Handler {
return func(c *fiber.Ctx) error {
var body map[string]string
if err := c.BodyParser(&body); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
conn := auth.GetConn(c)
for k, v := range body {
if !AllowedKeys[k] {
return fiber.NewError(400, "chave inválida: "+k)
}
if err := SetSetting(c.Context(), conn, k, v); err != nil {
return fiber.NewError(500, "erro ao guardar definição: "+k)
}
}
s, err := GetSettings(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao obter definições")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
+45
View File
@@ -0,0 +1,45 @@
package settings
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
var AllowedKeys = map[string]bool{
"company_name": true,
"company_nif": true,
"company_address": true,
"company_iban": true,
"company_phone": true,
"company_email": true,
}
func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) {
rows, err := conn.Query(ctx, `SELECT key, value FROM tenant_settings`)
if err != nil {
return nil, fmt.Errorf("settings: get: %w", err)
}
defer rows.Close()
result := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("settings: scan: %w", err)
}
result[k] = v
}
return result, rows.Err()
}
func SetSetting(ctx context.Context, conn *pgxpool.Conn, key, value string) error {
_, err := conn.Exec(ctx,
`INSERT INTO tenant_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
key, value)
if err != nil {
return fmt.Errorf("settings: set %s: %w", key, err)
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package staff
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
ro := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/staff", append(ro, listStaffH())...)
app.Post("/api/v1/staff", append(write, createStaffH())...)
app.Put("/api/v1/staff/:id", append(write, updateStaffH())...)
app.Delete("/api/v1/staff/:id", append(write, deleteStaffH())...)
}
func listStaffH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListStaff(c.Context(), conn)
if err != nil {
return fiber.NewError(500, "erro ao listar técnicos")
}
if list == nil {
list = []*Staff{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type staffBody struct {
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Type string `json:"type"`
HourlyRate float64 `json:"hourly_rate"`
Active bool `json:"active"`
}
func createStaffH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b staffBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if b.Name == "" {
return fiber.NewError(400, "nome obrigatório")
}
if b.Type != "internal" && b.Type != "external" {
return fiber.NewError(400, "tipo deve ser 'internal' ou 'external'")
}
conn := auth.GetConn(c)
s, err := CreateStaff(c.Context(), conn, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate)
if err != nil {
return fiber.NewError(500, "erro ao criar técnico")
}
return c.Status(201).JSON(fiber.Map{"data": s, "error": nil})
}
}
func updateStaffH() fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Params("id")
var b staffBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if b.Name == "" {
return fiber.NewError(400, "nome obrigatório")
}
conn := auth.GetConn(c)
s, err := UpdateStaff(c.Context(), conn, id, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate, b.Active)
if err != nil {
return fiber.NewError(500, "erro ao actualizar técnico")
}
if s == nil {
return fiber.NewError(404, "técnico não encontrado")
}
return c.JSON(fiber.Map{"data": s, "error": nil})
}
}
func deleteStaffH() fiber.Handler {
return func(c *fiber.Ctx) error {
id := c.Params("id")
conn := auth.GetConn(c)
err := DeleteStaff(c.Context(), conn, id)
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "técnico não encontrado")
}
if err != nil {
return fiber.NewError(500, "erro ao eliminar técnico")
}
return c.SendStatus(204)
}
}
+106
View File
@@ -0,0 +1,106 @@
package staff
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Staff struct {
ID string `json:"id"`
UserID *string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Type string `json:"type"`
HourlyRate float64 `json:"hourly_rate"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
}
func ListStaff(ctx context.Context, conn *pgxpool.Conn) ([]*Staff, error) {
rows, err := conn.Query(ctx,
`SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
COALESCE(hourly_rate,0), active, created_at
FROM staff ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("staff: list: %w", err)
}
defer rows.Close()
var list []*Staff
for rows.Next() {
var s Staff
if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil {
return nil, fmt.Errorf("staff: scan: %w", err)
}
list = append(list, &s)
}
return list, rows.Err()
}
func GetStaffByID(ctx context.Context, conn *pgxpool.Conn, id string) (*Staff, error) {
row := conn.QueryRow(ctx,
`SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
COALESCE(hourly_rate,0), active, created_at
FROM staff WHERE id = $1`, id)
var s Staff
err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("staff: get: %w", err)
}
return &s, nil
}
func CreateStaff(ctx context.Context, conn *pgxpool.Conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error) {
row := conn.QueryRow(ctx,
`INSERT INTO staff (name, email, phone, type, hourly_rate)
VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, $5)
RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
COALESCE(hourly_rate,0), active, created_at`,
name, email, phone, staffType, hourlyRate)
var s Staff
if err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil {
return nil, fmt.Errorf("staff: create: %w", err)
}
return &s, nil
}
func UpdateStaff(ctx context.Context, conn *pgxpool.Conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error) {
row := conn.QueryRow(ctx,
`UPDATE staff SET name=$2, email=NULLIF($3,''), phone=NULLIF($4,''), type=$5,
hourly_rate=$6, active=$7
WHERE id=$1
RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type,
COALESCE(hourly_rate,0), active, created_at`,
id, name, email, phone, staffType, hourlyRate, active)
var s Staff
err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone,
&s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("staff: update: %w", err)
}
return &s, nil
}
func DeleteStaff(ctx context.Context, conn *pgxpool.Conn, id string) error {
tag, err := conn.Exec(ctx, `DELETE FROM staff WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("staff: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
+242
View File
@@ -0,0 +1,242 @@
package tenant
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/pkg/database"
)
func listTenantsHandler(repo *Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
list, err := repo.ListTenants(c.Context())
if err != nil {
return fiber.NewError(500, "erro ao listar oficinas")
}
if list == nil {
list = []*Tenant{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type createTenantRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
AdminName string `json:"admin_name"`
}
func createTenantHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
var req createTenantRequest
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if req.Name == "" || req.Slug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
return fiber.NewError(400, "todos os campos são obrigatórios")
}
existing, err := repo.GetTenantBySlug(c.Context(), req.Slug)
if err != nil {
return fiber.NewError(500, "erro interno")
}
if existing != nil {
return fiber.NewError(409, "slug já existe")
}
ten, err := repo.CreateTenant(c.Context(), req.Slug, req.Name)
if err != nil {
return fiber.NewError(500, "erro ao criar oficina")
}
if db != nil {
if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil {
return fiber.NewError(500, "erro ao provisionar schema")
}
}
hash, err := auth.HashPassword(req.AdminPassword)
if err != nil {
return fiber.NewError(500, "erro interno")
}
if db != nil {
if _, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin"); err != nil {
return fiber.NewError(500, "erro ao criar utilizador admin")
}
}
return c.Status(201).JSON(fiber.Map{"data": ten, "error": nil})
}
}
func generateInviteHandler(repo *Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
tenantID := c.Params("id")
ten, err := repo.GetTenantByID(c.Context(), tenantID)
if err != nil || ten == nil {
return fiber.NewError(404, "oficina não encontrada")
}
invite, err := repo.CreateInvite(c.Context(), &ten.ID, 72*time.Hour)
if err != nil {
return fiber.NewError(500, "erro ao gerar convite")
}
return c.Status(201).JSON(fiber.Map{
"data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt},
"error": nil,
})
}
}
func generatePlatformInviteHandler(repo *Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
invite, err := repo.CreateInvite(c.Context(), nil, 72*time.Hour)
if err != nil {
return fiber.NewError(500, "erro ao gerar convite")
}
return c.Status(201).JSON(fiber.Map{
"data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt},
"error": nil,
})
}
}
func getInviteHandler(repo *Repository) fiber.Handler {
return func(c *fiber.Ctx) error {
token := c.Params("token")
invite, err := repo.GetInviteByToken(c.Context(), token)
if err != nil {
return fiber.NewError(500, "erro interno")
}
if invite == nil {
return fiber.NewError(404, "convite não encontrado")
}
if invite.UsedAt != nil {
return fiber.NewError(410, "convite já foi utilizado")
}
if invite.ExpiresAt.Before(time.Now()) {
return fiber.NewError(410, "convite expirado")
}
return c.JSON(fiber.Map{"data": invite, "error": nil})
}
}
type redeemRequest struct {
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
AdminName string `json:"admin_name"`
}
func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
claims, ok := c.Locals("claims").(*auth.Claims)
if !ok {
return fiber.NewError(401, "autenticação necessária")
}
tenantID := c.Params("id")
ten, err := repo.GetTenantByID(c.Context(), tenantID)
if err != nil {
return fiber.NewError(500, "erro interno")
}
if ten == nil {
return fiber.NewError(404, "oficina não encontrada")
}
if ten.Status != "active" {
return fiber.NewError(404, "oficina não encontrada ou inativa")
}
token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret)
if err != nil {
return fiber.NewError(500, "erro ao gerar token")
}
return c.JSON(fiber.Map{
"data": fiber.Map{
"access_token": token,
"tenant": fiber.Map{
"id": ten.ID,
"name": ten.Name,
"slug": ten.Slug,
},
},
"error": nil,
})
}
}
func redeemInviteHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
token := c.Params("token")
invite, err := repo.GetInviteByToken(c.Context(), token)
if err != nil || invite == nil {
return fiber.NewError(404, "convite não encontrado")
}
if invite.UsedAt != nil || invite.ExpiresAt.Before(time.Now()) {
return fiber.NewError(410, "convite inválido ou expirado")
}
var req redeemRequest
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(400, "corpo do pedido inválido")
}
if req.TenantName == "" || req.TenantSlug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" {
return fiber.NewError(400, "todos os campos são obrigatórios")
}
if len(req.AdminPassword) < 8 {
return fiber.NewError(400, "password deve ter pelo menos 8 caracteres")
}
existing, _ := repo.GetTenantBySlug(c.Context(), req.TenantSlug)
if existing != nil {
return fiber.NewError(409, "slug já existe")
}
ten, err := repo.CreateTenant(c.Context(), req.TenantSlug, req.TenantName)
if err != nil {
return fiber.NewError(500, "erro ao criar oficina")
}
if db != nil {
if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil {
return fiber.NewError(500, "erro ao provisionar schema")
}
}
hash, err := auth.HashPassword(req.AdminPassword)
if err != nil {
return fiber.NewError(500, "erro interno")
}
var userID string
if db != nil {
user, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin")
if err != nil {
return fiber.NewError(500, "erro ao criar utilizador")
}
userID = user.ID
} else {
userID = "mock-user-id"
}
if err := repo.UseInvite(c.Context(), invite.ID); err != nil {
return fiber.NewError(500, "erro ao registar utilização do convite")
}
access, _ := auth.GenerateAccessToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
refresh, _ := auth.GenerateRefreshToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret)
auth.SetRefreshCookie(c, refresh, cfg)
return c.Status(201).JSON(fiber.Map{
"data": fiber.Map{"access_token": access, "tenant_slug": ten.Slug},
"error": nil,
})
}
}
+160
View File
@@ -0,0 +1,160 @@
package tenant_test
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/internal/tenant"
)
const handlerTestSecret = "test-secret-32-chars-minimum-ok!"
func buildAdminApp(repo *tenant.Repository, cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()})
}})
tenant.RegisterRoutes(app, repo, nil, cfg)
return app
}
func adminToken(t *testing.T, secret string) string {
t.Helper()
tok, err := auth.GenerateAccessToken("sa-1", "", "super_admin", secret)
require.NoError(t, err)
return tok
}
func TestListTenants_requiresAuth(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 401, resp.StatusCode)
}
func TestListTenants_success(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil)
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var result map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
assert.Nil(t, result["error"])
}
func TestTenantAccessHandler(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
ctx := t.Context()
slug := "access-test-" + t.Name()
ten, err := repo.CreateTenant(ctx, slug, "Tenant Access Test")
require.NoError(t, err)
t.Cleanup(func() {
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
})
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil)
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
var body struct {
Data struct {
AccessToken string `json:"access_token"`
Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
} `json:"tenant"`
} `json:"data"`
Error *string `json:"error"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Nil(t, body.Error)
assert.NotEmpty(t, body.Data.AccessToken)
assert.Equal(t, ten.ID, body.Data.Tenant.ID)
assert.Equal(t, "Tenant Access Test", body.Data.Tenant.Name)
assert.Equal(t, slug, body.Data.Tenant.Slug)
claims, err := auth.ValidateToken(body.Data.AccessToken, handlerTestSecret)
require.NoError(t, err)
assert.Equal(t, "sa-1", claims.UserID)
assert.Equal(t, ten.ID, claims.TenantID)
assert.Equal(t, "tenant_admin", claims.Role)
}
func TestTenantAccessHandler_notFound(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/nonexistent-id/access", nil)
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 404, resp.StatusCode)
}
func TestTenantAccessHandler_inactive(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
ctx := t.Context()
slug := "inactive-test-" + t.Name()
ten, err := repo.CreateTenant(ctx, slug, "Tenant Inactive Test")
require.NoError(t, err)
t.Cleanup(func() {
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
})
// Update tenant status to something other than "active"
_, err = db.Pool.Exec(ctx, "UPDATE public.tenants SET status = 'suspended' WHERE id = $1", ten.ID)
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil)
req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret))
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 404, resp.StatusCode)
}
func TestGetInvite_notFound(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
cfg := &config.Config{JWTSecret: handlerTestSecret}
app := buildAdminApp(repo, cfg)
req := httptest.NewRequest("GET", "/api/v1/invites/nonexistent-token", nil)
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 404, resp.StatusCode)
}
+219
View File
@@ -0,0 +1,219 @@
package tenant
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/pkg/database"
)
var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
type SuperAdmin struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Tenant struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type TenantUser struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Name string `json:"name"`
Active bool `json:"active"`
}
type Invite struct {
ID string `json:"id"`
TenantID *string `json:"tenant_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"`
}
type Repository struct {
db *database.DB
}
func NewRepository(db *database.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) GetSuperAdminByEmail(ctx context.Context, email string) (*SuperAdmin, error) {
row := r.db.Pool.QueryRow(ctx,
`SELECT id, email, password_hash, created_at FROM super_admins WHERE email = $1`, email)
var a SuperAdmin
err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant: get super admin: %w", err)
}
return &a, nil
}
func (r *Repository) CreateSuperAdmin(ctx context.Context, email, passwordHash string) (*SuperAdmin, error) {
row := r.db.Pool.QueryRow(ctx,
`INSERT INTO super_admins (email, password_hash) VALUES ($1, $2)
RETURNING id, email, password_hash, created_at`,
email, passwordHash)
var a SuperAdmin
if err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant: create super admin: %w", err)
}
return &a, nil
}
func (r *Repository) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
row := r.db.Pool.QueryRow(ctx,
`SELECT id, slug, name, status, created_at FROM tenants WHERE slug = $1`, slug)
var t Tenant
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant: get by slug: %w", err)
}
return &t, nil
}
func (r *Repository) GetTenantByID(ctx context.Context, id string) (*Tenant, error) {
row := r.db.Pool.QueryRow(ctx,
`SELECT id, slug, name, status, created_at FROM tenants WHERE id = $1`, id)
var t Tenant
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant: get by id: %w", err)
}
return &t, nil
}
func (r *Repository) ListTenants(ctx context.Context) ([]*Tenant, error) {
rows, err := r.db.Pool.Query(ctx,
`SELECT id, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("tenant: list: %w", err)
}
defer rows.Close()
var list []*Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant: list scan: %w", err)
}
list = append(list, &t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("tenant: list rows: %w", err)
}
return list, nil
}
func (r *Repository) CreateTenant(ctx context.Context, slug, name string) (*Tenant, error) {
row := r.db.Pool.QueryRow(ctx,
`INSERT INTO tenants (slug, name) VALUES ($1, $2)
RETURNING id, slug, name, status, created_at`,
slug, name)
var t Tenant
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant: create: %w", err)
}
return &t, nil
}
func (r *Repository) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*TenantUser, error) {
if !uuidRe.MatchString(tenantID) {
return nil, fmt.Errorf("tenant: invalid tenant ID format")
}
schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"`
row := r.db.Pool.QueryRow(ctx,
fmt.Sprintf(`SELECT id, email, password_hash, role, name, active FROM %s.users WHERE email = $1`, schema),
email)
var u TenantUser
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant: get user: %w", err)
}
return &u, nil
}
func (r *Repository) CreateTenantUser(ctx context.Context, tenantID, email, passwordHash, name, role string) (*TenantUser, error) {
if !uuidRe.MatchString(tenantID) {
return nil, fmt.Errorf("tenant: invalid tenant ID format")
}
schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"`
row := r.db.Pool.QueryRow(ctx,
fmt.Sprintf(`INSERT INTO %s.users (email, password_hash, name, role)
VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, role, name, active`, schema),
email, passwordHash, name, role)
var u TenantUser
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active); err != nil {
return nil, fmt.Errorf("tenant: create user: %w", err)
}
return &u, nil
}
func (r *Repository) CreateInvite(ctx context.Context, tenantID *string, expiresIn time.Duration) (*Invite, error) {
token := uuid.New().String()
expiresAt := time.Now().Add(expiresIn)
row := r.db.Pool.QueryRow(ctx,
`INSERT INTO invites (tenant_id, token, expires_at) VALUES ($1, $2, $3)
RETURNING id, tenant_id, token, expires_at, used_at, created_at`,
tenantID, token, expiresAt)
var inv Invite
if err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant: create invite: %w", err)
}
return &inv, nil
}
func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) {
row := r.db.Pool.QueryRow(ctx,
`SELECT id, tenant_id, token, expires_at, used_at, created_at FROM invites WHERE token = $1`, token)
var inv Invite
err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant: get invite: %w", err)
}
return &inv, nil
}
func (r *Repository) UseInvite(ctx context.Context, inviteID string) error {
tag, err := r.db.Pool.Exec(ctx,
`UPDATE invites SET used_at = NOW() WHERE id = $1`, inviteID)
if err != nil {
return fmt.Errorf("tenant: use invite: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenant: invite not found: %s", inviteID)
}
return nil
}
+113
View File
@@ -0,0 +1,113 @@
package tenant_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/tenant"
"github.com/techxcar/backend/pkg/database"
)
func setupDB(t *testing.T) *database.DB {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
db, err := database.New(url)
require.NoError(t, err)
t.Cleanup(db.Close)
return db
}
func TestGetSuperAdminByEmail_notFound(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
admin, err := repo.GetSuperAdminByEmail(context.Background(), "nobody@example.com")
require.NoError(t, err)
assert.Nil(t, admin)
}
func TestCreateAndGetSuperAdmin(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
ctx := context.Background()
email := "sa_" + time.Now().Format("20060102150405") + "@example.com"
admin, err := repo.CreateSuperAdmin(ctx, email, "hash123")
require.NoError(t, err)
require.NotNil(t, admin)
assert.NotEmpty(t, admin.ID)
assert.Equal(t, email, admin.Email)
found, err := repo.GetSuperAdminByEmail(ctx, email)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, admin.ID, found.ID)
t.Cleanup(func() {
db.Pool.Exec(ctx, "DELETE FROM super_admins WHERE id = $1", admin.ID)
})
}
func TestCreateAndListTenants(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
ctx := context.Background()
slug := "test-" + time.Now().Format("20060102150405")
ten, err := repo.CreateTenant(ctx, slug, "Test Workshop")
require.NoError(t, err)
require.NotNil(t, ten)
assert.NotEmpty(t, ten.ID)
assert.Equal(t, slug, ten.Slug)
assert.Equal(t, "active", ten.Status)
list, err := repo.ListTenants(ctx)
require.NoError(t, err)
found := false
for _, v := range list {
if v.ID == ten.ID {
found = true
}
}
assert.True(t, found)
t.Cleanup(func() {
db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID)
})
}
func TestCreateAndUseInvite(t *testing.T) {
db := setupDB(t)
repo := tenant.NewRepository(db)
ctx := context.Background()
invite, err := repo.CreateInvite(ctx, nil, 24*time.Hour)
require.NoError(t, err)
require.NotNil(t, invite)
assert.NotEmpty(t, invite.Token)
assert.Nil(t, invite.UsedAt)
assert.True(t, invite.ExpiresAt.After(time.Now()))
found, err := repo.GetInviteByToken(ctx, invite.Token)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, invite.ID, found.ID)
err = repo.UseInvite(ctx, invite.ID)
require.NoError(t, err)
used, err := repo.GetInviteByToken(ctx, invite.Token)
require.NoError(t, err)
assert.NotNil(t, used.UsedAt)
t.Cleanup(func() {
db.Pool.Exec(ctx, "DELETE FROM invites WHERE id = $1", invite.ID)
})
}
+60
View File
@@ -0,0 +1,60 @@
package tenant
import (
"context"
"github.com/gofiber/fiber/v2"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/pkg/database"
)
// loginAdapter adapts *Repository to satisfy auth.LoginRepository.
type loginAdapter struct{ repo *Repository }
func (a *loginAdapter) GetSuperAdminByEmail(ctx context.Context, email string) (*auth.LoginSuperAdmin, error) {
sa, err := a.repo.GetSuperAdminByEmail(ctx, email)
if err != nil || sa == nil {
return nil, err
}
return &auth.LoginSuperAdmin{ID: sa.ID, Email: sa.Email, PasswordHash: sa.PasswordHash}, nil
}
func (a *loginAdapter) GetTenantBySlug(ctx context.Context, slug string) (*auth.LoginTenant, error) {
t, err := a.repo.GetTenantBySlug(ctx, slug)
if err != nil || t == nil {
return nil, err
}
return &auth.LoginTenant{ID: t.ID, Status: t.Status}, nil
}
func (a *loginAdapter) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*auth.LoginUser, error) {
u, err := a.repo.GetTenantUserByEmail(ctx, tenantID, email)
if err != nil || u == nil {
return nil, err
}
return &auth.LoginUser{ID: u.ID, Email: u.Email, PasswordHash: u.PasswordHash, Role: u.Role, Name: u.Name, Active: u.Active}, nil
}
// LoginAdapter returns an auth.LoginRepository backed by repo.
func LoginAdapter(repo *Repository) auth.LoginRepository {
return &loginAdapter{repo: repo}
}
func RegisterRoutes(app *fiber.App, repo *Repository, db *database.DB, cfg *config.Config) {
// Public invite routes
invites := app.Group("/api/v1/invites")
invites.Get("/:token", getInviteHandler(repo))
invites.Post("/:token/redeem", redeemInviteHandler(repo, db, cfg))
// Super-admin routes
admin := app.Group("/api/v1/admin",
auth.RequireAuth(cfg.JWTSecret),
auth.RequireRole("super_admin"),
)
admin.Get("/tenants", listTenantsHandler(repo))
admin.Post("/tenants", createTenantHandler(repo, db, cfg))
admin.Post("/tenants/:id/invite", generateInviteHandler(repo))
admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg))
admin.Post("/invites", generatePlatformInviteHandler(repo))
}
+200
View File
@@ -0,0 +1,200 @@
package workorder
import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/pkg/database"
)
func RegisterRoutes(app *fiber.App, db *database.DB, secret string) {
ro := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager", "technician"),
auth.TenantMiddleware(db),
}
write := []fiber.Handler{
auth.RequireAuth(secret),
auth.RequireRole("tenant_admin", "manager"),
auth.TenantMiddleware(db),
}
app.Get("/api/v1/work-orders", append(ro, listWOsH())...)
app.Post("/api/v1/work-orders", append(write, createWOH())...)
app.Get("/api/v1/work-orders/:id", append(ro, getWODetailH())...)
app.Put("/api/v1/work-orders/:id", append(write, updateWOH())...)
app.Post("/api/v1/work-orders/:id/transition", append(write, transitionWOH())...)
app.Post("/api/v1/work-orders/:id/items", append(write, addItemH())...)
app.Delete("/api/v1/work-orders/:id/items/:itemId", append(write, removeItemH())...)
app.Post("/api/v1/work-orders/:id/staff-hours", append(write, addStaffHoursH())...)
app.Delete("/api/v1/work-orders/:id/staff-hours/:shId", append(write, removeStaffHoursH())...)
}
func listWOsH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
list, err := ListWorkOrders(c.Context(), conn, c.Query("status"))
if err != nil {
return fiber.NewError(500, "erro ao listar ordens")
}
if list == nil {
list = []*WorkOrder{}
}
return c.JSON(fiber.Map{"data": list, "error": nil})
}
}
type woBody struct {
ClientID string `json:"client_id"`
VehicleID string `json:"vehicle_id"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
}
func createWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
claims, _ := c.Locals("claims").(*auth.Claims)
createdBy := ""
if claims != nil {
createdBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy)
if err != nil {
return fiber.NewError(500, "erro ao criar ordem")
}
return c.Status(201).JSON(fiber.Map{"data": wo, "error": nil})
}
}
func getWODetailH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
detail, err := GetWorkOrderDetail(c.Context(), conn, c.Params("id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao obter ordem")
}
return c.JSON(fiber.Map{"data": detail, "error": nil})
}
}
func updateWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
conn := auth.GetConn(c)
wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fiber.NewError(404, "ordem não encontrada")
}
return fiber.NewError(500, "erro ao actualizar ordem")
}
return c.JSON(fiber.Map{"data": wo, "error": nil})
}
}
func transitionWOH() fiber.Handler {
return func(c *fiber.Ctx) error {
var body struct {
Status string `json:"status"`
}
if err := c.BodyParser(&body); err != nil || body.Status == "" {
return fiber.NewError(400, "status é obrigatório")
}
claims, _ := c.Locals("claims").(*auth.Claims)
changedBy := ""
if claims != nil {
changedBy = claims.UserID
}
conn := auth.GetConn(c)
wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy)
if err != nil {
return fiber.NewError(400, err.Error())
}
return c.JSON(fiber.Map{"data": wo, "error": nil})
}
}
type woItemBody struct {
CatalogItemID string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
}
func addItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b woItemBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.Description == "" || b.Qty <= 0 || b.UnitPrice < 0 {
return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios")
}
conn := auth.GetConn(c)
item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct)
if err != nil {
return fiber.NewError(500, "erro ao adicionar item")
}
return c.Status(201).JSON(fiber.Map{"data": item, "error": nil})
}
}
func removeItemH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := RemoveItem(c.Context(), conn, c.Params("id"), c.Params("itemId")); err != nil {
return fiber.NewError(500, "erro ao remover item")
}
return c.SendStatus(204)
}
}
type staffHoursBody struct {
StaffID string `json:"staff_id"`
Hours float64 `json:"hours"`
CostPerHour float64 `json:"cost_per_hour"`
}
func addStaffHoursH() fiber.Handler {
return func(c *fiber.Ctx) error {
var b staffHoursBody
if err := c.BodyParser(&b); err != nil {
return fiber.NewError(400, "corpo inválido")
}
if b.StaffID == "" || b.Hours <= 0 {
return fiber.NewError(400, "funcionário e horas são obrigatórios")
}
conn := auth.GetConn(c)
sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour)
if err != nil {
return fiber.NewError(500, "erro ao adicionar horas")
}
return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil})
}
}
func removeStaffHoursH() fiber.Handler {
return func(c *fiber.Ctx) error {
conn := auth.GetConn(c)
if err := RemoveStaffHours(c.Context(), conn, c.Params("id"), c.Params("shId")); err != nil {
return fiber.NewError(500, "erro ao remover horas")
}
return c.SendStatus(204)
}
}
+236
View File
@@ -0,0 +1,236 @@
package workorder
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type WorkOrder struct {
ID string `json:"id"`
Number int `json:"number"`
ClientID *string `json:"client_id"`
VehicleID *string `json:"vehicle_id"`
Status string `json:"status"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WOItem struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
CatalogItemID *string `json:"catalog_item_id"`
Description string `json:"description"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
DiscountPct float64 `json:"discount_pct"`
Total float64 `json:"total"`
}
type WOStaffHours struct {
ID string `json:"id"`
WorkOrderID string `json:"work_order_id"`
StaffID string `json:"staff_id"`
Hours float64 `json:"hours"`
CostPerHour float64 `json:"cost_per_hour"`
Total float64 `json:"total"`
}
type WorkOrderDetail struct {
WorkOrder
Items []*WOItem `json:"items"`
StaffHours []*WOStaffHours `json:"staff_hours"`
}
var allowedTransitions = map[string][]string{
"open": {"in_progress", "cancelled"},
"in_progress": {"completed", "cancelled"},
"completed": {"invoiced", "cancelled"},
"invoiced": {},
"cancelled": {},
}
func ValidateTransition(from, to string) error {
nexts, ok := allowedTransitions[from]
if !ok {
return errors.New("estado desconhecido")
}
for _, n := range nexts {
if n == to {
return nil
}
}
return errors.New("transição inválida")
}
func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) {
q := `SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
FROM work_orders`
args := []any{}
if status != "" {
q += " WHERE status = $1"
args = append(args, status)
}
q += " ORDER BY created_at DESC"
rows, err := conn.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var list []*WorkOrder
for rows.Next() {
var wo WorkOrder
if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil {
return nil, err
}
list = append(list, &wo)
}
return list, rows.Err()
}
func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by)
VALUES (
NULLIF($1,'')::uuid,
NULLIF($2,'')::uuid,
NULLIF($3,''),
(SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1)
)
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
clientID, vehicleID, internalNotes, createdBy).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid,
internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW()
WHERE id=$1
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, clientID, vehicleID, internalNotes, clientNotes).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
return &wo, err
}
func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) {
var fromStatus string
if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil {
return nil, errors.New("ordem não encontrada")
}
if err := ValidateTransition(fromStatus, toStatus); err != nil {
return nil, err
}
var wo WorkOrder
err := conn.QueryRow(ctx, `
UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1
RETURNING id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`,
id, toStatus).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
_, _ = conn.Exec(ctx, `
INSERT INTO wo_status_log (work_order_id, from_status, to_status, changed_by)
VALUES ($1, $2, $3, (SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1))`,
id, fromStatus, toStatus, changedBy)
return &wo, nil
}
func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*WorkOrderDetail, error) {
var wo WorkOrder
err := conn.QueryRow(ctx, `
SELECT id, number, client_id, vehicle_id, status,
COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at
FROM work_orders WHERE id=$1`, id).
Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID,
&wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt)
if err != nil {
return nil, err
}
detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}}
rows, err := conn.Query(ctx, `
SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total
FROM wo_items WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var i WOItem
if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description,
&i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil {
return nil, err
}
detail.Items = append(detail.Items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
shRows, err := conn.Query(ctx, `
SELECT id, work_order_id, staff_id, hours, cost_per_hour, total
FROM wo_staff_hours WHERE work_order_id=$1`, id)
if err != nil {
return nil, err
}
defer shRows.Close()
for shRows.Next() {
var sh WOStaffHours
if err := shRows.Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total); err != nil {
return nil, err
}
detail.StaffHours = append(detail.StaffHours, &sh)
}
return detail, shRows.Err()
}
func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) {
var i WOItem
err := conn.QueryRow(ctx, `
INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct)
VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6)
RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`,
woID, catalogItemID, description, qty, unitPrice, discountPct).
Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total)
return &i, err
}
func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID)
return err
}
func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string, hours, costPerHour float64) (*WOStaffHours, error) {
var sh WOStaffHours
err := conn.QueryRow(ctx, `
INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour)
VALUES ($1, $2, $3, $4)
RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`,
woID, staffID, hours, costPerHour).
Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total)
return &sh, err
}
func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error {
_, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID)
return err
}
@@ -0,0 +1,90 @@
package workorder_test
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/internal/workorder"
)
func getTestConn(t *testing.T) *pgxpool.Conn {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
require.NoError(t, err)
t.Cleanup(func() { pool.Close() })
conn, err := pool.Acquire(context.Background())
require.NoError(t, err)
t.Cleanup(func() { conn.Release() })
_, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public")
require.NoError(t, err)
return conn
}
func TestWorkOrderCRUD(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
assert.NotEmpty(t, wo.ID)
assert.Equal(t, "open", wo.Status)
list, err := workorder.ListWorkOrders(ctx, conn, "")
require.NoError(t, err)
assert.GreaterOrEqual(t, len(list), 1)
detail, err := workorder.GetWorkOrderDetail(ctx, conn, wo.ID)
require.NoError(t, err)
assert.Equal(t, wo.ID, detail.ID)
assert.Empty(t, detail.Items)
assert.Empty(t, detail.StaffHours)
}
func TestWorkOrderTransition(t *testing.T) {
conn := getTestConn(t)
ctx := context.Background()
wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "")
require.NoError(t, err)
wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "")
require.NoError(t, err)
assert.Equal(t, "in_progress", wo2.Status)
_, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "")
assert.Error(t, err, "invalid transition should error")
}
func TestAllowedTransitions(t *testing.T) {
tests := []struct {
from string
to string
valid bool
}{
{"open", "in_progress", true},
{"open", "cancelled", true},
{"open", "completed", false},
{"in_progress", "completed", true},
{"in_progress", "cancelled", true},
{"in_progress", "open", false},
{"completed", "invoiced", true},
{"completed", "cancelled", true},
{"invoiced", "cancelled", false},
}
for _, tt := range tests {
err := workorder.ValidateTransition(tt.from, tt.to)
if tt.valid {
assert.NoError(t, err, "%s->%s should be valid", tt.from, tt.to)
} else {
assert.Error(t, err, "%s->%s should be invalid", tt.from, tt.to)
}
}
}