From 77f414bf02d97cfbc1659426659c942e21a1a6ea Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sat, 20 Dec 2025 10:32:54 -0300 Subject: [PATCH] Add database migrations runner --- .../repository/postgres/migrations.go | 152 ++++++++++++++++++ .../postgres/migrations/0001_init.sql | 111 +++++++++++++ .../internal/repository/postgres/postgres.go | 123 -------------- backend/internal/server/server.go | 2 +- 4 files changed, 264 insertions(+), 124 deletions(-) create mode 100644 backend/internal/repository/postgres/migrations.go create mode 100644 backend/internal/repository/postgres/migrations/0001_init.sql diff --git a/backend/internal/repository/postgres/migrations.go b/backend/internal/repository/postgres/migrations.go new file mode 100644 index 0000000..691d835 --- /dev/null +++ b/backend/internal/repository/postgres/migrations.go @@ -0,0 +1,152 @@ +package postgres + +import ( + "context" + "embed" + "fmt" + "sort" + "time" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +type migration struct { + version int + name string + sql string +} + +// ApplyMigrations ensures the database schema is up to date. +func (r *Repository) ApplyMigrations(ctx context.Context) error { + migrations, err := loadMigrations() + if err != nil { + return err + } + + if err := r.ensureMigrationsTable(ctx); err != nil { + return err + } + + applied, err := r.fetchAppliedVersions(ctx) + if err != nil { + return err + } + + for _, m := range migrations { + if applied[m.version] { + continue + } + + if err := r.applyMigration(ctx, m); err != nil { + return err + } + } + + return nil +} + +func loadMigrations() ([]migration, error) { + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return nil, fmt.Errorf("read migrations dir: %w", err) + } + + migrations := make([]migration, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + version, err := parseMigrationVersion(name) + if err != nil { + return nil, err + } + + content, err := migrationFiles.ReadFile("migrations/" + name) + if err != nil { + return nil, fmt.Errorf("read migration %s: %w", name, err) + } + + migrations = append(migrations, migration{ + version: version, + name: name, + sql: string(content), + }) + } + + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].version < migrations[j].version + }) + + return migrations, nil +} + +func parseMigrationVersion(name string) (int, error) { + var version int + if _, err := fmt.Sscanf(name, "%d_", &version); err == nil { + return version, nil + } + if _, err := fmt.Sscanf(name, "%d-", &version); err == nil { + return version, nil + } + return 0, fmt.Errorf("invalid migration filename: %s", name) +} + +func (r *Repository) ensureMigrationsTable(ctx context.Context) error { + const schema = ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INT PRIMARY KEY, + name TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL +);` + if _, err := r.db.ExecContext(ctx, schema); err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + return nil +} + +func (r *Repository) fetchAppliedVersions(ctx context.Context) (map[int]bool, error) { + rows, err := r.db.QueryxContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("fetch applied migrations: %w", err) + } + defer rows.Close() + + applied := make(map[int]bool) + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + return nil, fmt.Errorf("scan applied migration: %w", err) + } + applied[version] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read applied migrations: %w", err) + } + return applied, nil +} + +func (r *Repository) applyMigration(ctx context.Context, m migration) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %d: %w", m.version, err) + } + + if _, err := tx.ExecContext(ctx, m.sql); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply migration %d: %w", m.version, err) + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version, name, applied_at) VALUES ($1, $2, $3)`, m.version, m.name, time.Now().UTC()); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record migration %d: %w", m.version, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %d: %w", m.version, err) + } + + return nil +} diff --git a/backend/internal/repository/postgres/migrations/0001_init.sql b/backend/internal/repository/postgres/migrations/0001_init.sql new file mode 100644 index 0000000..df08e3f --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0001_init.sql @@ -0,0 +1,111 @@ +CREATE TABLE IF NOT EXISTS companies ( + id UUID PRIMARY KEY, + cnpj TEXT NOT NULL UNIQUE, + corporate_name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'farmacia', + license_number TEXT NOT NULL, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + latitude DOUBLE PRECISION NOT NULL DEFAULT 0, + longitude DOUBLE PRECISION NOT NULL DEFAULT 0, + city TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + company_id UUID NOT NULL REFERENCES companies(id), + role TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY, + seller_id UUID NOT NULL REFERENCES companies(id), + name TEXT NOT NULL, + description TEXT, + batch TEXT NOT NULL, + expires_at DATE NOT NULL, + price_cents BIGINT NOT NULL, + stock BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS inventory_adjustments ( + id UUID PRIMARY KEY, + product_id UUID NOT NULL REFERENCES products(id), + delta BIGINT NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY, + buyer_id UUID NOT NULL REFERENCES companies(id), + seller_id UUID NOT NULL REFERENCES companies(id), + status TEXT NOT NULL, + total_cents BIGINT NOT NULL, + shipping_recipient_name TEXT, + shipping_street TEXT, + shipping_number TEXT, + shipping_complement TEXT, + shipping_district TEXT, + shipping_city TEXT, + shipping_state TEXT, + shipping_zip_code TEXT, + shipping_country TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL REFERENCES orders(id), + product_id UUID NOT NULL REFERENCES products(id), + quantity BIGINT NOT NULL, + unit_cents BIGINT NOT NULL, + batch TEXT NOT NULL, + expires_at DATE NOT NULL +); + +CREATE TABLE IF NOT EXISTS cart_items ( + id UUID PRIMARY KEY, + buyer_id UUID NOT NULL REFERENCES companies(id), + product_id UUID NOT NULL REFERENCES products(id), + quantity BIGINT NOT NULL, + unit_cents BIGINT NOT NULL, + batch TEXT, + expires_at DATE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (buyer_id, product_id) +); + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL UNIQUE REFERENCES orders(id), + buyer_id UUID NOT NULL REFERENCES companies(id), + seller_id UUID NOT NULL REFERENCES companies(id), + rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5), + comment TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id); + +CREATE TABLE IF NOT EXISTS shipments ( + id UUID PRIMARY KEY, + order_id UUID NOT NULL UNIQUE REFERENCES orders(id), + carrier TEXT NOT NULL, + tracking_code TEXT, + external_tracking TEXT, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index b1fe856..c37d657 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -952,126 +952,3 @@ func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*doma return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil } - -// InitSchema applies a minimal schema for development environments. -func (r *Repository) InitSchema(ctx context.Context) error { - schema := ` -CREATE TABLE IF NOT EXISTS companies ( - id UUID PRIMARY KEY, - cnpj TEXT NOT NULL UNIQUE, - corporate_name TEXT NOT NULL, - category TEXT NOT NULL DEFAULT 'farmacia', - license_number TEXT NOT NULL, - is_verified BOOLEAN NOT NULL DEFAULT FALSE, - latitude DOUBLE PRECISION NOT NULL DEFAULT 0, - longitude DOUBLE PRECISION NOT NULL DEFAULT 0, - city TEXT NOT NULL DEFAULT '', - state TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - - -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY, - company_id UUID NOT NULL REFERENCES companies(id), - role TEXT NOT NULL, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS products ( - id UUID PRIMARY KEY, - seller_id UUID NOT NULL REFERENCES companies(id), - name TEXT NOT NULL, - description TEXT, - batch TEXT NOT NULL, - expires_at DATE NOT NULL, - price_cents BIGINT NOT NULL, - stock BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS inventory_adjustments ( - id UUID PRIMARY KEY, - product_id UUID NOT NULL REFERENCES products(id), - delta BIGINT NOT NULL, - reason TEXT, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS orders ( - id UUID PRIMARY KEY, - buyer_id UUID NOT NULL REFERENCES companies(id), - seller_id UUID NOT NULL REFERENCES companies(id), - status TEXT NOT NULL, - total_cents BIGINT NOT NULL, - shipping_recipient_name TEXT, - shipping_street TEXT, - shipping_number TEXT, - shipping_complement TEXT, - shipping_district TEXT, - shipping_city TEXT, - shipping_state TEXT, - shipping_zip_code TEXT, - shipping_country TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS order_items ( - id UUID PRIMARY KEY, - order_id UUID NOT NULL REFERENCES orders(id), - product_id UUID NOT NULL REFERENCES products(id), - quantity BIGINT NOT NULL, - unit_cents BIGINT NOT NULL, - batch TEXT NOT NULL, - expires_at DATE NOT NULL -); - -CREATE TABLE IF NOT EXISTS cart_items ( - id UUID PRIMARY KEY, - buyer_id UUID NOT NULL REFERENCES companies(id), - product_id UUID NOT NULL REFERENCES products(id), - quantity BIGINT NOT NULL, - unit_cents BIGINT NOT NULL, - batch TEXT, - expires_at DATE, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE (buyer_id, product_id) -); - -CREATE TABLE IF NOT EXISTS reviews ( - id UUID PRIMARY KEY, - order_id UUID NOT NULL UNIQUE REFERENCES orders(id), - buyer_id UUID NOT NULL REFERENCES companies(id), - seller_id UUID NOT NULL REFERENCES companies(id), - rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5), - comment TEXT, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id); - -CREATE TABLE IF NOT EXISTS shipments ( - id UUID PRIMARY KEY, - order_id UUID NOT NULL UNIQUE REFERENCES orders(id), - carrier TEXT NOT NULL, - tracking_code TEXT, - external_tracking TEXT, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); -` - - if _, err := r.db.ExecContext(ctx, schema); err != nil { - return fmt.Errorf("apply schema: %w", err) - } - return nil -} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index b6205b4..12af33f 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -123,7 +123,7 @@ func (s *Server) Start(ctx context.Context) error { } repo := postgres.New(s.db) - if err := repo.InitSchema(ctx); err != nil { + if err := repo.ApplyMigrations(ctx); err != nil { return err }