Add database migrations runner
This commit is contained in:
parent
c3006064f7
commit
77f414bf02
4 changed files with 264 additions and 124 deletions
152
backend/internal/repository/postgres/migrations.go
Normal file
152
backend/internal/repository/postgres/migrations.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
111
backend/internal/repository/postgres/migrations/0001_init.sql
Normal file
111
backend/internal/repository/postgres/migrations/0001_init.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ func (s *Server) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := postgres.New(s.db)
|
repo := postgres.New(s.db)
|
||||||
if err := repo.InitSchema(ctx); err != nil {
|
if err := repo.ApplyMigrations(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue