Inicial
This commit is contained in:
@@ -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