saveinmed/seeder-api/pkg/seeder/seeder.go
Tiago Yamamoto 59919cb875 feat(marketplace): implement admin dashboard with full CRUD operations
- Add Header component with top navigation menu
- Create DashboardLayout with nested routing under /dashboard
- Implement Users, Companies, Products, Orders CRUD pages
- Add adminService with all API operations
- Update apiClient to return data directly with patch support
- Fix TypeScript errors in existing pages
- Update seeder README with detailed user credentials table
- Fix fmt.Sprintf format verb in seeder.go
2025-12-22 07:22:01 -03:00

469 lines
15 KiB
Go

package seeder
import (
"context"
"encoding/json"
"fmt"
"log"
"math/rand"
"time"
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
// Anápolis, GO coordinates
const (
AnapolisLat = -16.3281
AnapolisLng = -48.9530
VarianceKm = 5.0 // ~5km spread
)
// Product categories with sample medicines
var categories = []string{
"Analgésicos",
"Antibióticos",
"Anti-inflamatórios",
"Cardiovasculares",
"Dermatológicos",
"Vitaminas",
"Oftálmicos",
"Respiratórios",
}
var medicamentos = []struct {
Name string
Category string
BasePrice int64
}{
{"Dipirona 500mg", "Analgésicos", 1299},
{"Paracetamol 750mg", "Analgésicos", 899},
{"Ibuprofeno 400mg", "Anti-inflamatórios", 1599},
{"Nimesulida 100mg", "Anti-inflamatórios", 2499},
{"Amoxicilina 500mg", "Antibióticos", 3499},
{"Azitromicina 500mg", "Antibióticos", 4999},
{"Losartana 50mg", "Cardiovasculares", 2199},
{"Atenolol 50mg", "Cardiovasculares", 1899},
{"Vitamina C 1g", "Vitaminas", 1599},
{"Vitamina D 2000UI", "Vitaminas", 2299},
{"Cetoconazol Creme", "Dermatológicos", 2899},
{"Dexametasona Creme", "Dermatológicos", 1999},
{"Colírio Lubrificante", "Oftálmicos", 3299},
{"Lacrifilm 15ml", "Oftálmicos", 2899},
{"Loratadina 10mg", "Respiratórios", 1799},
{"Dexclorfeniramina 2mg", "Respiratórios", 1599},
{"Omeprazol 20mg", "Gastrointestinais", 1999},
{"Pantoprazol 40mg", "Gastrointestinais", 2999},
{"Metformina 850mg", "Antidiabéticos", 1299},
{"Glibenclamida 5mg", "Antidiabéticos", 999},
}
var pharmacyNames = []string{
"Farmácia Popular", "Drogaria Central", "Farma Vida", "Drogasil",
"Farmácia São João", "Droga Raia", "Ultrafarma", "Farma Bem",
"Drogaria Moderna", "Farmácia União", "Saúde Total", "Bem Estar",
"Vida Saudável", "Mais Saúde", "Farmácia do Povo", "Super Farma",
}
// Seed dispatches based on mode
func Seed(dsn, mode string) (string, error) {
if mode == "lean" {
return SeedLean(dsn)
}
return SeedFull(dsn)
}
func SeedLean(dsn string) (string, error) {
if dsn == "" {
return "", fmt.Errorf("DATABASE_URL not set")
}
db, err := sqlx.Connect("pgx", dsn)
if err != nil {
return "", fmt.Errorf("db connect: %v", err)
}
defer db.Close()
ctx := context.Background()
log.Println("🧹 [Lean] Resetting database...")
// Re-create tables
mustExec(db, `DROP TABLE IF EXISTS inventory_adjustments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS order_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS orders CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS cart_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS reviews CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS products CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS users CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS companies CASCADE`)
// Create tables (Schema must match backend migrations!)
mustExec(db, `CREATE TABLE 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
)`)
mustExec(db, `CREATE TABLE users (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
role TEXT NOT NULL,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE 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
)`)
// Helper for hashing
hashPwd := func(pwd string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
return string(h)
}
defaultPwdHash := hashPwd("123456")
// Fixed Pharmacies Data
pharmacies := []struct {
Name string
CNPJ string
Lat float64
Lng float64
Suffix string // for usernames/emails e.g. "1" -> dono1, email1
}{
{
Name: "Farmácia Central",
CNPJ: "11111111000111",
Lat: AnapolisLat,
Lng: AnapolisLng,
Suffix: "1",
},
{
Name: "Farmácia Jundiaí",
CNPJ: "22222222000122",
Lat: AnapolisLat + 0.015, // Slightly North
Lng: AnapolisLng + 0.010, // East
Suffix: "2",
},
{
Name: "Farmácia Jaiara",
CNPJ: "33333333000133",
Lat: AnapolisLat + 0.030, // More North
Lng: AnapolisLng - 0.010, // West
Suffix: "3",
},
{
Name: "Farmácia Universitária",
CNPJ: "44444444000144",
Lat: AnapolisLat - 0.020, // South
Lng: AnapolisLng + 0.020, // East
Suffix: "4",
},
}
now := time.Now().UTC()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
createdUsers := []string{}
for _, ph := range pharmacies {
// 1. Create Company
companyID := uuid.Must(uuid.NewV7())
_, err = db.ExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
companyID, ph.CNPJ, ph.Name, "farmacia", fmt.Sprintf("CRF-GO-%s", ph.Suffix), true, ph.Lat, ph.Lng, "Anápolis", "GO", now, now,
)
if err != nil {
return "", fmt.Errorf("create library %s: %v", ph.Name, err)
}
// 2. Create Users (Dono, Colab, Entregador)
roles := []struct {
Role string
UserBase string
NameBase string
}{
{"Dono", "dono", "Dono"},
{"Colaborador", "colab", "Colaborador"},
{"Entregador", "entregador", "Entregador"},
}
for _, r := range roles {
uid := uuid.Must(uuid.NewV7())
username := fmt.Sprintf("%s%s", r.UserBase, ph.Suffix)
email := fmt.Sprintf("%s%s@saveinmed.com", r.UserBase, ph.Suffix)
name := fmt.Sprintf("%s %s", r.NameBase, ph.Name)
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', '%s', '%s', '%s', '%s', true, '%s', NOW(), NOW())`,
uid, companyID, r.Role, name, username, email, defaultPwdHash,
))
createdUsers = append(createdUsers, fmt.Sprintf("%s (%s)", username, r.Role))
}
// 3. Create Products (20-50 products per pharmacy)
numProds := 20 + rng.Intn(31) // 20-50
prods := generateProducts(rng, companyID, numProds)
for _, p := range prods {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product lean: %v", err)
}
}
log.Printf("✅ [Lean] %s created with %d products", ph.Name, len(prods))
}
// 4. Create Global Admin (linked to first pharmacy for FK constraint, or standalone if nullable? Schema says NOT NULL)
// Build Admin linked to "Farmácia Central" (Suffix 1)
// Find ID of first pharmacy? I need to track it.
// Actually, just query it or store it.
// Simpler: I'll just create a separate "Admin Company" or link to one.
// Linking to Central is fine.
var centralID string
err = db.Get(&centralID, "SELECT id FROM companies WHERE cnpj = '11111111000111'")
if err == nil {
adminID := uuid.Must(uuid.NewV7())
mustExec(db, fmt.Sprintf(`INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES ('%s', '%s', 'Admin', 'Administrador Global', 'admin', 'admin@saveinmed.com', true, '%s', NOW(), NOW())`,
adminID, centralID, hashPwd("admin123"),
))
createdUsers = append(createdUsers, "admin (Admin)")
}
return fmt.Sprintf("Lean seed completed. 4 Pharmacies. Users: %d. Pass: 123456 (admin: admin123)", len(createdUsers)), nil
}
func SeedFull(dsn string) (string, error) {
if dsn == "" {
return "", fmt.Errorf("DATABASE_URL not set")
}
db, err := sqlx.Connect("pgx", dsn)
if err != nil {
return "", fmt.Errorf("db connect: %v", err)
}
defer db.Close()
ctx := context.Background()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Re-create tables to ensure schema match
log.Println("Re-creating tables...")
mustExec(db, `DROP TABLE IF EXISTS inventory_adjustments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS order_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS orders CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS cart_items CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS reviews CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS products CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS users CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS companies CASCADE`)
mustExec(db, `CREATE TABLE 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
)`)
mustExec(db, `CREATE TABLE users (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
role TEXT NOT NULL,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE 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
)`)
// Generate 400 tenants
tenants := generateTenants(rng, 400)
log.Printf("Generated %d tenants", len(tenants))
// Insert tenants
for _, t := range tenants {
_, err := db.NamedExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at)
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)
ON CONFLICT (cnpj) DO NOTHING`, t)
if err != nil {
log.Printf("insert tenant %s: %v", t["corporate_name"], err)
}
}
log.Println("Tenants inserted")
// Generate and insert products for each tenant
totalProducts := 0
for _, t := range tenants {
tenantID := t["id"].(uuid.UUID)
numProducts := 20 + rng.Intn(481) // 20-500
products := generateProducts(rng, tenantID, numProducts)
totalProducts += len(products)
for _, p := range products {
_, err := db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, description, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :description, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
// Try again with minimal columns if the issue is named params mismatch
// Wait, I see duplicate description in named exec above?
// ORIGINAL:
// INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
// VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
// I should verify the original file content.
// Line 152: INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
// Line 153: VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
// I will correct my manual typing in this block to match original.
_, err = db.NamedExecContext(ctx, `
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
ON CONFLICT DO NOTHING`, p)
if err != nil {
log.Printf("insert product: %v", err)
}
}
}
}
log.Printf("Inserted %d products total", totalProducts)
// Output summary
summary := map[string]interface{}{
"tenants": len(tenants),
"products": totalProducts,
"location": "Anápolis, GO",
}
out, _ := json.MarshalIndent(summary, "", " ")
return string(out), nil
}
func generateTenants(rng *rand.Rand, count int) []map[string]interface{} {
now := time.Now().UTC()
tenants := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
name := fmt.Sprintf("%s %d", pharmacyNames[rng.Intn(len(pharmacyNames))], i+1)
cnpj := generateCNPJ(rng)
// Vary lat/lng within ~5km of Anápolis center
latOffset := (rng.Float64() - 0.5) * 0.09 // ~5km
lngOffset := (rng.Float64() - 0.5) * 0.09
tenants = append(tenants, map[string]interface{}{
"id": id,
"cnpj": cnpj,
"corporate_name": name,
"category": "farmacia",
"license_number": fmt.Sprintf("CRF-GO-%05d", rng.Intn(99999)),
"is_verified": rng.Float32() > 0.3, // 70% verified
"latitude": AnapolisLat + latOffset,
"longitude": AnapolisLng + lngOffset,
"city": "Anápolis",
"state": "GO",
"created_at": now,
"updated_at": now,
})
}
return tenants
}
func generateProducts(rng *rand.Rand, sellerID uuid.UUID, count int) []map[string]interface{} {
now := time.Now().UTC()
products := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
med := medicamentos[rng.Intn(len(medicamentos))]
// Random expiration: 30 days to 2 years from now
daysToExpire := 30 + rng.Intn(700)
expiresAt := now.AddDate(0, 0, daysToExpire)
// Price variation: -20% to +30%
priceMultiplier := 0.8 + rng.Float64()*0.5
price := int64(float64(med.BasePrice) * priceMultiplier)
products = append(products, map[string]interface{}{
"id": id,
"seller_id": sellerID,
"name": med.Name,
"description": fmt.Sprintf("%s - %s", med.Name, med.Category),
"batch": fmt.Sprintf("L%d%03d", now.Year(), rng.Intn(999)),
"expires_at": expiresAt,
"price_cents": price,
"stock": int64(10 + rng.Intn(500)),
"created_at": now,
"updated_at": now,
})
}
return products
}
func generateCNPJ(rng *rand.Rand) string {
return fmt.Sprintf("%02d.%03d.%03d/%04d-%02d",
rng.Intn(99), rng.Intn(999), rng.Intn(999),
rng.Intn(9999)+1, rng.Intn(99))
}
func mustExec(db *sqlx.DB, query string) {
_, err := db.Exec(query)
if err != nil {
log.Printf("exec %s: %v", query, err) // Don't fatal, just log
}
}