This commit is contained in:
Luciano Milani
2026-07-02 12:47:55 +01:00
commit 5de37bb512
132 changed files with 28495 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package database
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
migratepg "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
)
var validTenantID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,63}$`)
type DB struct {
Pool *pgxpool.Pool
dsn string
}
func New(dsn string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("database: failed to create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database: failed to ping: %w", err)
}
return &DB{Pool: pool, dsn: dsn}, nil
}
func (db *DB) Ping() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Pool.Ping(ctx)
}
func (db *DB) Close() {
db.Pool.Close()
}
func tenantSchema(tenantID string) string {
return fmt.Sprintf("tenant_%s", strings.ReplaceAll(tenantID, "-", "_"))
}
func (db *DB) SetTenantSchema(ctx context.Context, tenantID string) error {
if !validTenantID.MatchString(tenantID) {
return fmt.Errorf("database: invalid tenantID %q", tenantID)
}
schema := tenantSchema(tenantID)
_, err := db.Pool.Exec(ctx, fmt.Sprintf(`SET search_path = "%s", public`, schema))
return err
}
func (db *DB) ResetSchema(ctx context.Context) error {
_, err := db.Pool.Exec(ctx, "SET search_path = public")
return err
}
func (db *DB) stdDB(dsn string) (*sql.DB, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
return stdlib.OpenDB(*cfg.ConnConfig), nil
}
func (db *DB) MigratePublic(dsn, migrationsPath string) error {
stdDB, err := db.stdDB(dsn)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
defer stdDB.Close()
driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: "public"})
if err != nil {
return fmt.Errorf("migrate: driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+migrationsPath,
"postgres",
driver,
)
if err != nil {
return fmt.Errorf("migrate: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate: %w", err)
}
return nil
}
// MigrateTenantSchema ensures the tenant schema exists and runs all pending migrations.
// Safe to call on existing tenants — golang-migrate tracks state in schema_migrations.
// The 000001 migration uses IF NOT EXISTS so re-running it is idempotent.
func (db *DB) MigrateTenantSchema(ctx context.Context, tenantID, migrationsPath string) error {
if !validTenantID.MatchString(tenantID) {
return fmt.Errorf("database: invalid tenantID %q", tenantID)
}
schema := tenantSchema(tenantID)
// Ensure schema exists before handing off to golang-migrate.
conn, err := db.Pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("migrate tenant: acquire: %w", err)
}
_, execErr := conn.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS "%s"`, schema))
conn.Release()
if execErr != nil {
return fmt.Errorf("migrate tenant: create schema: %w", execErr)
}
// Append search_path to DSN so every connection golang-migrate opens
// automatically resolves unqualified table names to the tenant schema.
tenantDSN := db.dsn
sep := "?"
if strings.Contains(tenantDSN, "?") {
sep = "&"
}
tenantDSN += sep + "search_path=" + schema
stdDB, err := db.stdDB(tenantDSN)
if err != nil {
return fmt.Errorf("migrate tenant: open stdDB: %w", err)
}
defer stdDB.Close()
driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: schema})
if err != nil {
return fmt.Errorf("migrate tenant: driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance("file://"+migrationsPath, "postgres", driver)
if err != nil {
return fmt.Errorf("migrate tenant: init: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("migrate tenant %s: %w", tenantID, err)
}
return nil
}
// ProvisionTenantSchema is an alias kept for call-site compatibility.
func (db *DB) ProvisionTenantSchema(ctx context.Context, tenantID, migrationsPath string) error {
return db.MigrateTenantSchema(ctx, tenantID, migrationsPath)
}
// MigrateAllTenantSchemas runs pending migrations against every registered tenant.
// Called at startup so existing tenants always get new migration files applied.
func (db *DB) MigrateAllTenantSchemas(ctx context.Context, migrationsPath string) error {
rows, err := db.Pool.Query(ctx, `SELECT id FROM public.tenants`)
if err != nil {
return fmt.Errorf("migrate all tenants: query: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("migrate all tenants: scan: %w", err)
}
ids = append(ids, id)
}
for _, id := range ids {
if err := db.MigrateTenantSchema(ctx, id, migrationsPath); err != nil {
return err
}
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package database_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/techxcar/backend/pkg/database"
)
func TestNew_invalidURL(t *testing.T) {
_, err := database.New("not-a-valid-url")
assert.Error(t, err)
}
func TestNew_valid(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
db, err := database.New(url)
require.NoError(t, err)
defer db.Close()
assert.NoError(t, db.Ping())
}
+135
View File
@@ -0,0 +1,135 @@
package pdf
import (
"fmt"
"os"
"path/filepath"
"github.com/go-pdf/fpdf"
)
type DocMeta struct {
CompanyName string
CompanyNIF string
CompanyAddress string
CompanyIBAN string
CompanyPhone string
CompanyEmail string
DocType string // "Fatura" or "Orçamento"
DocNumber string // e.g. "FAT/2026/001"
IssuedAt string // e.g. "30/06/2026"
ClientName string
ClientNIF string
VehiclePlate string
}
type LineItem struct {
Description string
Qty float64
UnitPrice float64
DiscountPct float64
Total float64
}
func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("pdf: mkdir: %w", err)
}
f := fpdf.New("P", "mm", "A4", "")
f.AddPage()
f.SetMargins(15, 15, 15)
// Header
f.SetFont("Helvetica", "B", 18)
f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "")
f.SetFont("Helvetica", "B", 14)
f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyNIF != "" {
f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "")
} else {
f.CellFormat(120, 5, "", "", 0, "L", false, 0, "")
}
f.SetFont("Helvetica", "", 11)
f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "")
f.SetFont("Helvetica", "", 9)
if meta.CompanyAddress != "" {
f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false)
}
f.Ln(3)
curY := f.GetY()
f.SetXY(135, curY-3)
f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "")
f.SetY(curY + 3)
f.Ln(3)
// Client block
if meta.ClientName != "" {
f.SetFont("Helvetica", "B", 9)
f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "")
f.SetFont("Helvetica", "", 9)
f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "")
if meta.ClientNIF != "" {
f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "")
}
if meta.VehiclePlate != "" {
f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "")
}
f.Ln(4)
}
// Table header
f.SetFillColor(50, 50, 50)
f.SetTextColor(255, 255, 255)
f.SetFont("Helvetica", "B", 9)
f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "")
f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "")
f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "")
f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "")
// Table rows
f.SetFillColor(245, 245, 245)
f.SetTextColor(0, 0, 0)
f.SetFont("Helvetica", "", 9)
fill := false
for _, item := range items {
f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "")
f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.Total), "1", 1, "R", fill, 0, "")
fill = !fill
}
// Totals
var subtotal float64
for _, item := range items {
subtotal += item.Total
}
grandTotal := subtotal + staffTotal
f.Ln(3)
f.SetFont("Helvetica", "", 9)
if staffTotal > 0 {
f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "")
f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "")
f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "")
}
f.SetFont("Helvetica", "B", 10)
f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "")
f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "")
// IBAN footer
if meta.CompanyIBAN != "" {
f.Ln(8)
f.SetFont("Helvetica", "", 8)
f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "")
}
return f.OutputFileAndClose(outPath)
}
+36
View File
@@ -0,0 +1,36 @@
package redis
import (
"context"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
)
type Redis struct {
Client *goredis.Client
}
func New(redisURL string) (*Redis, error) {
opts, err := goredis.ParseURL(redisURL)
if err != nil {
return nil, fmt.Errorf("redis: invalid URL: %w", err)
}
client := goredis.NewClient(opts)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("redis: failed to connect: %w", err)
}
return &Redis{Client: client}, nil
}
func (r *Redis) Close() error {
return r.Client.Close()
}
+37
View File
@@ -0,0 +1,37 @@
package redis_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
redispkg "github.com/techxcar/backend/pkg/redis"
)
func TestNew_invalidURL(t *testing.T) {
_, err := redispkg.New("not-a-valid-url")
assert.Error(t, err)
}
func TestNew_valid(t *testing.T) {
url := os.Getenv("TEST_REDIS_URL")
if url == "" {
t.Skip("TEST_REDIS_URL not set, skipping integration test")
}
rdb, err := redispkg.New(url)
require.NoError(t, err)
defer rdb.Close()
ctx := context.Background()
err = rdb.Client.Set(ctx, "test_key", "test_value", time.Second).Err()
assert.NoError(t, err)
val, err := rdb.Client.Get(ctx, "test_key").Result()
assert.NoError(t, err)
assert.Equal(t, "test_value", val)
}