Inicial
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user