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
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/migrations ./migrations
RUN mkdir -p /app/storage && chown -R app:app /app
USER app
EXPOSE 8080
CMD ["./server"]
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"log"
"time"
"github.com/joho/godotenv"
"github.com/techxcar/backend/internal/auth"
"github.com/techxcar/backend/internal/config"
"github.com/techxcar/backend/internal/server"
"github.com/techxcar/backend/internal/tenant"
"github.com/techxcar/backend/pkg/database"
redispkg "github.com/techxcar/backend/pkg/redis"
)
func main() {
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, reading from environment")
}
cfg, err := config.Load()
if err != nil {
log.Fatal("Config error:", err)
}
db, err := database.New(cfg.DatabaseURL)
if err != nil {
log.Fatal("Database error:", err)
}
defer db.Close()
if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil {
log.Fatal("Migration error:", err)
}
if err := db.MigrateAllTenantSchemas(context.Background(), "migrations/tenant"); err != nil {
log.Fatal("Tenant migration error:", err)
}
rdb, err := redispkg.New(cfg.RedisURL)
if err != nil {
log.Fatal("Redis error:", err)
}
defer rdb.Close()
seedSuperAdmin(db, cfg)
app := server.New(server.Deps{Config: cfg, DB: db, Redis: rdb})
log.Printf("TechXCar API v0.2.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv)
log.Fatal(app.Listen(":" + cfg.Port))
}
func seedSuperAdmin(db *database.DB, cfg *config.Config) {
if cfg.InitialAdminEmail == "" || cfg.InitialAdminPassword == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
repo := tenant.NewRepository(db)
existing, err := repo.GetSuperAdminByEmail(ctx, cfg.InitialAdminEmail)
if err != nil || existing != nil {
return
}
hash, err := auth.HashPassword(cfg.InitialAdminPassword)
if err != nil {
log.Printf("Warning: could not hash initial admin password: %v", err)
return
}
if _, err := repo.CreateSuperAdmin(ctx, cfg.InitialAdminEmail, hash); err != nil {
log.Printf("Warning: could not create initial super admin: %v", err)
return
}
log.Printf("Initial super admin created: %s", cfg.InitialAdminEmail)
}
+43
View File
@@ -0,0 +1,43 @@
module github.com/techxcar/backend
go 1.25.0
require github.com/stretchr/testify v1.11.1
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/gofiber/fiber/v2 v2.52.13 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.20.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+92
View File
@@ -0,0 +1,92 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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)
}
}
}
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS platform_settings;
DROP TABLE IF EXISTS super_admins;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS tenants;
@@ -0,0 +1,36 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'pending')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
email TEXT,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS super_admins (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS platform_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_invites_tenant_id;
CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id);
ALTER TABLE invites ALTER COLUMN tenant_id SET NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE invites ALTER COLUMN tenant_id DROP NOT NULL;
DROP INDEX IF EXISTS idx_invites_tenant_id;
CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id) WHERE tenant_id IS NOT NULL;
@@ -0,0 +1,12 @@
DROP TABLE IF EXISTS tenant_settings;
DROP TABLE IF EXISTS expenses;
DROP TABLE IF EXISTS invoices;
DROP TABLE IF EXISTS wo_status_log;
DROP TABLE IF EXISTS wo_staff_hours;
DROP TABLE IF EXISTS wo_items;
DROP TABLE IF EXISTS work_orders;
DROP TABLE IF EXISTS catalog_items;
DROP TABLE IF EXISTS staff;
DROP TABLE IF EXISTS vehicles;
DROP TABLE IF EXISTS clients;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,132 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('tenant_admin', 'manager', 'technician')),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
nif TEXT,
phone TEXT,
email TEXT,
address TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
plate TEXT NOT NULL,
brand TEXT NOT NULL,
model TEXT NOT NULL,
year INT,
vin TEXT,
mileage INT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
type TEXT NOT NULL CHECK (type IN ('internal', 'external')),
hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS catalog_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL,
unit TEXT NOT NULL CHECK (unit IN ('un', 'hora', 'litro', 'kg')),
base_price NUMERIC(10,2) NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
number SERIAL UNIQUE,
client_id UUID REFERENCES clients(id) ON DELETE SET NULL,
vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')),
internal_notes TEXT,
client_notes TEXT,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS wo_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL,
description TEXT NOT NULL,
qty NUMERIC(10,3) NOT NULL DEFAULT 1,
unit_price NUMERIC(10,2) NOT NULL,
discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
total NUMERIC(10,2) GENERATED ALWAYS AS (qty * unit_price * (1 - discount_pct / 100)) STORED
);
CREATE TABLE IF NOT EXISTS wo_staff_hours (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
hours NUMERIC(6,2) NOT NULL,
cost_per_hour NUMERIC(10,2) NOT NULL,
total NUMERIC(10,2) GENERATED ALWAYS AS (hours * cost_per_hour) STORED
);
CREATE TABLE IF NOT EXISTS wo_status_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE RESTRICT,
type TEXT NOT NULL CHECK (type IN ('quote', 'invoice')),
number SERIAL UNIQUE,
pdf_path TEXT,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL,
type TEXT NOT NULL CHECK (type IN ('fuel', 'parts', 'tools', 'other')),
amount NUMERIC(10,2) NOT NULL,
description TEXT,
date DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tenant_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_work_orders_status ON work_orders(status);
CREATE INDEX IF NOT EXISTS idx_work_orders_client_id ON work_orders(client_id);
CREATE INDEX IF NOT EXISTS idx_vehicles_client_id ON vehicles(client_id);
CREATE INDEX IF NOT EXISTS idx_vehicles_plate ON vehicles(plate);
CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(date);
@@ -0,0 +1 @@
ALTER TABLE vehicles DROP COLUMN IF EXISTS fuel_type;
@@ -0,0 +1 @@
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS fuel_type TEXT;
+185
View File
@@ -0,0 +1,185 @@
package database
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
migratepg "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
)
var validTenantID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,63}$`)
type DB struct {
Pool *pgxpool.Pool
dsn string
}
func New(dsn string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("database: failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database: failed to ping: %w", err)
}
return &DB{Pool: pool, dsn: dsn}, nil
}
func (db *DB) Ping() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Pool.Ping(ctx)
}
func (db *DB) Close() {
db.Pool.Close()
}
func tenantSchema(tenantID string) string {
return fmt.Sprintf("tenant_%s", strings.ReplaceAll(tenantID, "-", "_"))
}
func (db *DB) SetTenantSchema(ctx context.Context, tenantID string) error {
if !validTenantID.MatchString(tenantID) {
return fmt.Errorf("database: invalid tenantID %q", tenantID)
}
schema := tenantSchema(tenantID)
_, err := db.Pool.Exec(ctx, fmt.Sprintf(`SET search_path = "%s", public`, schema))
return err
}
func (db *DB) ResetSchema(ctx context.Context) error {
_, err := db.Pool.Exec(ctx, "SET search_path = public")
return err
}
func (db *DB) stdDB(dsn string) (*sql.DB, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
return stdlib.OpenDB(*cfg.ConnConfig), nil
}
func (db *DB) MigratePublic(dsn, migrationsPath string) error {
stdDB, err := db.stdDB(dsn)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
defer stdDB.Close()
driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: "public"})
if err != nil {
return fmt.Errorf("migrate: driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+migrationsPath,
"postgres",
driver,
)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate: %w", err)
}
return nil
}
// MigrateTenantSchema ensures the tenant schema exists and runs all pending migrations.
// Safe to call on existing tenants — golang-migrate tracks state in schema_migrations.
// The 000001 migration uses IF NOT EXISTS so re-running it is idempotent.
func (db *DB) MigrateTenantSchema(ctx context.Context, tenantID, migrationsPath string) error {
if !validTenantID.MatchString(tenantID) {
return fmt.Errorf("database: invalid tenantID %q", tenantID)
}
schema := tenantSchema(tenantID)
// Ensure schema exists before handing off to golang-migrate.
conn, err := db.Pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("migrate tenant: acquire: %w", err)
}
_, execErr := conn.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS "%s"`, schema))
conn.Release()
if execErr != nil {
return fmt.Errorf("migrate tenant: create schema: %w", execErr)
}
// Append search_path to DSN so every connection golang-migrate opens
// automatically resolves unqualified table names to the tenant schema.
tenantDSN := db.dsn
sep := "?"
if strings.Contains(tenantDSN, "?") {
sep = "&"
}
tenantDSN += sep + "search_path=" + schema
stdDB, err := db.stdDB(tenantDSN)
if err != nil {
return fmt.Errorf("migrate tenant: open stdDB: %w", err)
}
defer stdDB.Close()
driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: schema})
if err != nil {
return fmt.Errorf("migrate tenant: driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance("file://"+migrationsPath, "postgres", driver)
if err != nil {
return fmt.Errorf("migrate tenant: init: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate tenant %s: %w", tenantID, err)
}
return nil
}
// ProvisionTenantSchema is an alias kept for call-site compatibility.
func (db *DB) ProvisionTenantSchema(ctx context.Context, tenantID, migrationsPath string) error {
return db.MigrateTenantSchema(ctx, tenantID, migrationsPath)
}
// MigrateAllTenantSchemas runs pending migrations against every registered tenant.
// Called at startup so existing tenants always get new migration files applied.
func (db *DB) MigrateAllTenantSchemas(ctx context.Context, migrationsPath string) error {
rows, err := db.Pool.Query(ctx, `SELECT id FROM public.tenants`)
if err != nil {
return fmt.Errorf("migrate all tenants: query: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("migrate all tenants: scan: %w", err)
}
ids = append(ids, id)
}
for _, id := range ids {
if err := db.MigrateTenantSchema(ctx, id, migrationsPath); err != nil {
return err
}
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package database_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/pkg/database"
)
func TestNew_invalidURL(t *testing.T) {
_, err := database.New("not-a-valid-url")
assert.Error(t, err)
}
func TestNew_valid(t *testing.T) {
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)
defer db.Close()
assert.NoError(t, db.Ping())
}
+135
View File
@@ -0,0 +1,135 @@
package pdf
import (
"fmt"
"os"
"path/filepath"
"github.com/go-pdf/fpdf"
)
type DocMeta struct {
CompanyName string
CompanyNIF string
CompanyAddress string
CompanyIBAN string
CompanyPhone string
CompanyEmail string
DocType string // "Fatura" or "Orçamento"
DocNumber string // e.g. "FAT/2026/001"
IssuedAt string // e.g. "30/06/2026"
ClientName string
ClientNIF string
VehiclePlate string
}
type LineItem struct {
Description string
Qty float64
UnitPrice float64
DiscountPct float64
Total float64
}
func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("pdf: mkdir: %w", err)
}
f := fpdf.New("P", "mm", "A4", "")
f.AddPage()
f.SetMargins(15, 15, 15)
// Header
f.SetFont("Helvetica", "B", 18)
f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "")
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false)
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Client block
if meta.ClientName != "" {
f.SetFont("Helvetica", "B", 9)
f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "")
f.SetFont("Helvetica", "", 9)
f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "")
if meta.ClientNIF != "" {
f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "")
}
if meta.VehiclePlate != "" {
f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "")
}
f.Ln(4)
}
// Table header
f.SetFillColor(50, 50, 50)
f.SetTextColor(255, 255, 255)
f.SetFont("Helvetica", "B", 9)
f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "")
// Table rows
f.SetFillColor(245, 245, 245)
f.SetTextColor(0, 0, 0)
f.SetFont("Helvetica", "", 9)
fill := false
for _, item := range items {
f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.Total), "1", 1, "R", fill, 0, "")
fill = !fill
}
// Totals
var subtotal float64
for _, item := range items {
subtotal += item.Total
}
grandTotal := subtotal + staffTotal
f.Ln(3)
f.SetFont("Helvetica", "", 9)
if staffTotal > 0 {
f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "")
f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "")
}
f.SetFont("Helvetica", "B", 10)
f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "")
f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "")
// IBAN footer
if meta.CompanyIBAN != "" {
f.Ln(8)
f.SetFont("Helvetica", "", 8)
f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "")
}
return f.OutputFileAndClose(outPath)
}
+36
View File
@@ -0,0 +1,36 @@
package redis
import (
"context"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
)
type Redis struct {
Client *goredis.Client
}
func New(redisURL string) (*Redis, error) {
opts, err := goredis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("redis: invalid URL: %w", err)
}
client := goredis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("redis: failed to connect: %w", err)
}
return &Redis{Client: client}, nil
}
func (r *Redis) Close() error {
return r.Client.Close()
}
+37
View File
@@ -0,0 +1,37 @@
package redis_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
redispkg "github.com/techxcar/backend/pkg/redis"
)
func TestNew_invalidURL(t *testing.T) {
_, err := redispkg.New("not-a-valid-url")
assert.Error(t, err)
}
func TestNew_valid(t *testing.T) {
url := os.Getenv("TEST_REDIS_URL")
if url == "" {
t.Skip("TEST_REDIS_URL not set, skipping integration test")
}
rdb, err := redispkg.New(url)
require.NoError(t, err)
defer rdb.Close()
ctx := context.Background()
err = rdb.Client.Set(ctx, "test_key", "test_value", time.Second).Err()
assert.NoError(t, err)
val, err := rdb.Client.Get(ctx, "test_key").Result()
assert.NoError(t, err)
assert.Equal(t, "test_value", val)
}