fix: align dev auth and bootstrap superadmin

This commit is contained in:
Tiago Yamamoto 2026-03-04 10:41:40 -06:00
parent 3ca9f50d0c
commit b00d0fe99c
17 changed files with 612 additions and 105 deletions

View file

@ -8,10 +8,6 @@ BACKEND_PORT=8214
# Database Configuration # Database Configuration
DATABASE_URL=postgres://postgres:123@localhost:55432/saveinmed?sslmode=disable DATABASE_URL=postgres://postgres:123@localhost:55432/saveinmed?sslmode=disable
ADMIN_NAME=Admin Master
ADMIN_USERNAME=admin
ADMIN_EMAIL=andre.fr93@gmail.com
ADMIN_PASSWORD=teste1234
# JWT Authentication # JWT Authentication
JWT_SECRET=your-secret-key-here JWT_SECRET=your-secret-key-here

8
backend/.env.template Normal file
View file

@ -0,0 +1,8 @@
STORE_CORS=https://dev.saveinmed.com.br,https://mkt-dev.saveinmed.com.br,https://docs.medusajs.com
ADMIN_CORS=https://back-dev.saveinmed.com.br,https://dev.saveinmed.com.br,https://docs.medusajs.com
AUTH_CORS=https://dev.saveinmed.com.br,https://mkt-dev.saveinmed.com.br,https://back-dev.saveinmed.com.br,https://docs.medusajs.com
REDIS_URL=redis://localhost:6379
JWT_SECRET=supersecret
COOKIE_SECRET=supersecret
DATABASE_URL=postgres://coolify:Shared#User#Password#123!4@postgres-db:5432/saveinmed?sslmode=disable
DB_NAME=saveinmed

View file

@ -105,11 +105,9 @@ JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=24h JWT_EXPIRES_IN=24h
PASSWORD_PEPPER=your-pepper PASSWORD_PEPPER=your-pepper
# Admin Seeding (criado automaticamente na inicialização) # Superadmin Bootstrap
ADMIN_NAME=Administrator # O backend cria automaticamente um superadmin padrao ao subir.
ADMIN_USERNAME=admin # As credenciais iniciais sao geradas e registradas no log apenas na primeira criacao.
ADMIN_EMAIL=admin@saveinmed.com
ADMIN_PASSWORD=admin123
# CORS # CORS
CORS_ORIGINS=* CORS_ORIGINS=*

View file

@ -15,6 +15,13 @@ import (
"github.com/saveinmed/backend-go/internal/domain" "github.com/saveinmed/backend-go/internal/domain"
) )
const (
seederSuperadminName = "SaveInMed Superadmin"
seederSuperadminUsername = "superadmin"
seederSuperadminEmail = "superadmin@saveinmed.com"
seederSuperadminPassword = "sim-seeder-superadmin"
)
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@ -58,23 +65,24 @@ func cleanDB(ctx context.Context, db *sqlx.DB) {
} }
func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) { func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
// 1. Seed Admin // 1. Seed platform superadmin
adminCompanyID := uuid.Must(uuid.NewV7()) adminCompanyID := uuid.Must(uuid.NewV7())
createCompany(ctx, db, &domain.Company{ createCompany(ctx, db, &domain.Company{
ID: adminCompanyID, ID: adminCompanyID,
CNPJ: "00000000000000", CNPJ: "00000000000000",
CorporateName: "SaveInMed Admin", CorporateName: "SaveInMed Platform",
Category: "admin", Category: "platform",
LicenseNumber: "ADMIN", LicenseNumber: "SUPERADMIN",
IsVerified: true, IsVerified: true,
}) })
createUser(ctx, db, &domain.User{ createUser(ctx, db, &domain.User{
CompanyID: adminCompanyID, CompanyID: adminCompanyID,
Role: "Admin", Role: "superadmin",
Name: cfg.AdminName, Name: seederSuperadminName,
Username: cfg.AdminUsername, Username: seederSuperadminUsername,
Email: cfg.AdminEmail, Email: seederSuperadminEmail,
}, cfg.AdminPassword, cfg.PasswordPepper) Superadmin: true,
}, seederSuperadminPassword, cfg.PasswordPepper)
// 2. Distributors (Sellers - SP Center) // 2. Distributors (Sellers - SP Center)
distributor1ID := uuid.Must(uuid.NewV7()) distributor1ID := uuid.Must(uuid.NewV7())

View file

@ -25,10 +25,6 @@ type Config struct {
CORSOrigins []string CORSOrigins []string
BackendHost string BackendHost string
SwaggerSchemes []string SwaggerSchemes []string
AdminName string
AdminUsername string
AdminEmail string
AdminPassword string
MercadoPagoPublicKey string MercadoPagoPublicKey string
MapboxAccessToken string MapboxAccessToken string
} }
@ -52,10 +48,6 @@ func Load() (*Config, error) {
CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}), CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}),
BackendHost: getEnv("BACKEND_HOST", ""), BackendHost: getEnv("BACKEND_HOST", ""),
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}), SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
AdminName: getEnv("ADMIN_NAME", "Administrator"),
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminEmail: getEnv("ADMIN_EMAIL", "admin@saveinmed.com"),
AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"),
MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"), MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"),
MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""), MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""),
} }

View file

@ -11,7 +11,7 @@ func TestLoadDefaults(t *testing.T) {
envVars := []string{ envVars := []string{
"APP_NAME", "BACKEND_PORT", "DATABASE_URL", "APP_NAME", "BACKEND_PORT", "DATABASE_URL",
"MERCADOPAGO_BASE_URL", "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN", "MERCADOPAGO_BASE_URL", "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN",
"PASSWORD_PEPPER", "CORS_ORIGINS", "ADMIN_NAME", "ADMIN_USERNAME", "ADMIN_EMAIL", "ADMIN_PASSWORD", "PASSWORD_PEPPER", "CORS_ORIGINS",
} }
origEnvs := make(map[string]string) origEnvs := make(map[string]string)
for _, key := range envVars { for _, key := range envVars {
@ -49,18 +49,6 @@ func TestLoadDefaults(t *testing.T) {
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" { if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" {
t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins) t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins)
} }
if cfg.AdminName != "Administrator" {
t.Errorf("expected AdminName 'Administrator', got '%s'", cfg.AdminName)
}
if cfg.AdminUsername != "admin" {
t.Errorf("expected AdminUsername 'admin', got '%s'", cfg.AdminUsername)
}
if cfg.AdminEmail != "admin@saveinmed.com" {
t.Errorf("expected AdminEmail 'admin@saveinmed.com', got '%s'", cfg.AdminEmail)
}
if cfg.AdminPassword != "admin123" {
t.Errorf("expected AdminPassword 'admin123', got '%s'", cfg.AdminPassword)
}
} }
func TestLoadFromEnv(t *testing.T) { func TestLoadFromEnv(t *testing.T) {
@ -75,11 +63,6 @@ func TestLoadFromEnv(t *testing.T) {
os.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com") os.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com")
os.Setenv("BACKEND_HOST", "api.test.local") os.Setenv("BACKEND_HOST", "api.test.local")
os.Setenv("SWAGGER_SCHEMES", "https, http") os.Setenv("SWAGGER_SCHEMES", "https, http")
os.Setenv("ADMIN_NAME", "CustomAdmin")
os.Setenv("ADMIN_USERNAME", "customadmin")
os.Setenv("ADMIN_EMAIL", "custom@example.com")
os.Setenv("ADMIN_PASSWORD", "securepass")
defer func() { defer func() {
os.Unsetenv("APP_NAME") os.Unsetenv("APP_NAME")
os.Unsetenv("BACKEND_PORT") os.Unsetenv("BACKEND_PORT")
@ -92,10 +75,6 @@ func TestLoadFromEnv(t *testing.T) {
os.Unsetenv("CORS_ORIGINS") os.Unsetenv("CORS_ORIGINS")
os.Unsetenv("BACKEND_HOST") os.Unsetenv("BACKEND_HOST")
os.Unsetenv("SWAGGER_SCHEMES") os.Unsetenv("SWAGGER_SCHEMES")
os.Unsetenv("ADMIN_NAME")
os.Unsetenv("ADMIN_USERNAME")
os.Unsetenv("ADMIN_EMAIL")
os.Unsetenv("ADMIN_PASSWORD")
}() }()
cfg, err := Load() cfg, err := Load()
@ -136,18 +115,6 @@ func TestLoadFromEnv(t *testing.T) {
if len(cfg.SwaggerSchemes) != 2 || cfg.SwaggerSchemes[0] != "https" || cfg.SwaggerSchemes[1] != "http" { if len(cfg.SwaggerSchemes) != 2 || cfg.SwaggerSchemes[0] != "https" || cfg.SwaggerSchemes[1] != "http" {
t.Errorf("expected SwaggerSchemes [https http], got %v", cfg.SwaggerSchemes) t.Errorf("expected SwaggerSchemes [https http], got %v", cfg.SwaggerSchemes)
} }
if cfg.AdminName != "CustomAdmin" {
t.Errorf("expected AdminName 'CustomAdmin', got '%s'", cfg.AdminName)
}
if cfg.AdminUsername != "customadmin" {
t.Errorf("expected AdminUsername 'customadmin', got '%s'", cfg.AdminUsername)
}
if cfg.AdminEmail != "custom@example.com" {
t.Errorf("expected AdminEmail 'custom@example.com', got '%s'", cfg.AdminEmail)
}
if cfg.AdminPassword != "securepass" {
t.Errorf("expected AdminPassword 'securepass', got '%s'", cfg.AdminPassword)
}
} }
func TestAddr(t *testing.T) { func TestAddr(t *testing.T) {

View file

@ -1090,7 +1090,7 @@ func (r *Repository) GetUserByUsername(ctx context.Context, username string) (*d
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User var user domain.User
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1` query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, superadmin, created_at, updated_at FROM users WHERE email = $1`
if err := r.db.GetContext(ctx, &user, query, email); err != nil { if err := r.db.GetContext(ctx, &user, query, email); err != nil {
return nil, err return nil, err
} }
@ -1101,7 +1101,7 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
user.UpdatedAt = time.Now().UTC() user.UpdatedAt = time.Now().UTC()
query := `UPDATE users query := `UPDATE users
SET company_id = :company_id, role = :role, name = :name, username = :username, email = :email, email_verified = :email_verified, password_hash = :password_hash, updated_at = :updated_at SET company_id = :company_id, role = :role, name = :name, username = :username, email = :email, email_verified = :email_verified, password_hash = :password_hash, superadmin = :superadmin, updated_at = :updated_at
WHERE id = :id` WHERE id = :id`
res, err := r.db.NamedExecContext(ctx, query, user) res, err := r.db.NamedExecContext(ctx, query, user)

View file

@ -22,6 +22,13 @@ import (
"github.com/saveinmed/backend-go/internal/usecase" "github.com/saveinmed/backend-go/internal/usecase"
) )
const (
bootstrapSuperadminName = "SaveInMed Superadmin"
bootstrapSuperadminUsername = "superadmin"
bootstrapSuperadminEmail = "superadmin@saveinmed.com"
bootstrapSuperadminPassword = "sim-superadmin"
)
// Server wires the infrastructure and exposes HTTP handlers. // Server wires the infrastructure and exposes HTTP handlers.
type Server struct { type Server struct {
cfg config.Config cfg config.Config
@ -65,8 +72,7 @@ func New(cfg config.Config) (*Server, error) {
}) })
auth := middleware.RequireAuth([]byte(cfg.JWTSecret)) auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin") // Keep for strict admin routes if any adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "superadmin")
// Allow Admin, Superadmin, Dono, Gerente, and Seller to manage products
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller") productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller")
// Companies (Empresas) // Companies (Empresas)
@ -212,22 +218,20 @@ func (s *Server) Start(ctx context.Context) error {
// return err // return err
// } // }
// Seed Admin // Seed platform superadmin automatically
if s.cfg.AdminEmail != "" && s.cfg.AdminPassword != "" { {
// Checks if admin already exists existingUser, err := repo.GetUserByEmail(ctx, bootstrapSuperadminEmail)
_, err := repo.GetUserByEmail(ctx, s.cfg.AdminEmail)
if err != nil { if err != nil {
// If not found, create log.Printf("Seeding superadmin user: %s", bootstrapSuperadminEmail)
log.Printf("Seeding admin user: %s", s.cfg.AdminEmail)
// 1. Create/Get Admin Company // 1. Create/Get platform company
adminCNPJ := "00000000000000" adminCNPJ := "00000000000000"
company := &domain.Company{ company := &domain.Company{
ID: uuid.Nil, ID: uuid.Nil,
CNPJ: adminCNPJ, CNPJ: adminCNPJ,
CorporateName: "SaveInMed Admin", CorporateName: "SaveInMed Platform",
Category: "admin", Category: "platform",
LicenseNumber: "ADMIN", LicenseNumber: "SUPERADMIN",
IsVerified: true, IsVerified: true,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
@ -257,26 +261,39 @@ func (s *Server) Start(ctx context.Context) error {
// In that case, CreateCompany will fail on CNPJ constraint. // In that case, CreateCompany will fail on CNPJ constraint.
err := s.svc.RegisterAccount(ctx, company, &domain.User{ err := s.svc.RegisterAccount(ctx, company, &domain.User{
Role: "Admin", Role: "superadmin",
Name: s.cfg.AdminName, Name: bootstrapSuperadminName,
Username: s.cfg.AdminUsername, Username: bootstrapSuperadminUsername,
Email: s.cfg.AdminEmail, Email: bootstrapSuperadminEmail,
}, s.cfg.AdminPassword) Superadmin: true,
}, bootstrapSuperadminPassword)
if err != nil { if err != nil {
// If error is duplicate key on company, maybe we should fetch the company and try creating user only? log.Printf("Failed to seed superadmin: %v", err)
// For now, let's log error but not fail startup hard
log.Printf("Failed to seed admin (may already exist): %v", err)
// Continue startup anyway
} else { } else {
// FORCE VERIFY the admin company // FORCE VERIFY the platform company
if _, err := s.svc.VerifyCompany(ctx, company.ID); err != nil { if _, err := s.svc.VerifyCompany(ctx, company.ID); err != nil {
log.Printf("Failed to verify admin company: %v", err) log.Printf("Failed to verify platform company: %v", err)
} }
log.Printf("Admin user created successfully") log.Printf("Superadmin user created successfully")
log.Printf("Bootstrap superadmin credentials: email=%s password=%s", bootstrapSuperadminEmail, bootstrapSuperadminPassword)
} }
} else { } else {
log.Printf("Admin user %s already exists", s.cfg.AdminEmail) existingUser.Role = "superadmin"
existingUser.Superadmin = true
if existingUser.Name == "" {
existingUser.Name = bootstrapSuperadminName
}
if existingUser.Username == "" {
existingUser.Username = bootstrapSuperadminUsername
}
if err := s.svc.UpdateUser(ctx, existingUser, bootstrapSuperadminPassword); err != nil {
log.Printf("Failed to reconcile existing user %s as superadmin: %v", bootstrapSuperadminEmail, err)
} else {
log.Printf("Existing user %s reconciled as superadmin with bootstrap password", bootstrapSuperadminEmail)
log.Printf("Bootstrap superadmin credentials: email=%s password=%s", bootstrapSuperadminEmail, bootstrapSuperadminPassword)
}
} }
} }

View file

@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
} }
// URL base para redirecionamentos // URL base para redirecionamentos
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://saveinmed.com.br' const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://dev.saveinmed.com.br'
console.log('Base URL:', baseUrl) console.log('Base URL:', baseUrl)
console.log('Environment NEXT_PUBLIC_APP_URL:', process.env.NEXT_PUBLIC_APP_URL) console.log('Environment NEXT_PUBLIC_APP_URL:', process.env.NEXT_PUBLIC_APP_URL)

View file

@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
// Enviar Email para Admin // Enviar Email para Admin
try { try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br'; const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
const adminEmailHtml = ` const adminEmailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
// Enviar Email de Boas-vindas para o usuário // Enviar Email de Boas-vindas para o usuário
try { try {
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
const welcomeEmailHtml = ` const welcomeEmailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
@ -157,7 +157,7 @@ async function sendWhatsAppNotification(userData: UserData) {
const evolutionApiKey = process.env.EVOLUTION_API_KEY; const evolutionApiKey = process.env.EVOLUTION_API_KEY;
const instanceName = process.env.EVOLUTION_INSTANCE_NAME; const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
const adminWhatsApp = process.env.ADMIN_WHATSAPP; const adminWhatsApp = process.env.ADMIN_WHATSAPP;
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) { if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
throw new Error('Configurações do WhatsApp não encontradas'); throw new Error('Configurações do WhatsApp não encontradas');

View file

@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
try { try {
// Para modo teste do Resend, usar o email autorizado da conta // Para modo teste do Resend, usar o email autorizado da conta
const adminEmail = process.env.RESEND_TEST_EMAIL || 'gladistone.cabobo20@gmail.com'; // Email autorizado no Resend const adminEmail = process.env.RESEND_TEST_EMAIL || 'gladistone.cabobo20@gmail.com'; // Email autorizado no Resend
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
// Inicializar Resend apenas quando necessário // Inicializar Resend apenas quando necessário
const resend = getResendClient(); const resend = getResendClient();
@ -167,7 +167,7 @@ async function sendWhatsAppNotification(userData: UserData) {
const evolutionApiKey = process.env.EVOLUTION_API_KEY; const evolutionApiKey = process.env.EVOLUTION_API_KEY;
const instanceName = process.env.EVOLUTION_INSTANCE_NAME; const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
const adminWhatsApp = process.env.ADMIN_WHATSAPP; const adminWhatsApp = process.env.ADMIN_WHATSAPP;
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) { if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
throw new Error('Configurações do WhatsApp não encontradas'); throw new Error('Configurações do WhatsApp não encontradas');

View file

@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
// Simular envio de Email para Admin (log detalhado) // Simular envio de Email para Admin (log detalhado)
try { try {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br'; const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
console.log(`📧 [ADMIN EMAIL] Enviando para: ${adminEmail}`); console.log(`📧 [ADMIN EMAIL] Enviando para: ${adminEmail}`);
console.log(`📧 [ADMIN EMAIL] Assunto: 🎉 `); console.log(`📧 [ADMIN EMAIL] Assunto: 🎉 `);
@ -108,7 +108,7 @@ async function sendWhatsAppNotification(userData: UserData) {
const evolutionApiKey = process.env.EVOLUTION_API_KEY; const evolutionApiKey = process.env.EVOLUTION_API_KEY;
const instanceName = process.env.EVOLUTION_INSTANCE_NAME; const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
const adminWhatsApp = process.env.ADMIN_WHATSAPP; const adminWhatsApp = process.env.ADMIN_WHATSAPP;
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) { if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
throw new Error('Configurações do WhatsApp não encontradas'); throw new Error('Configurações do WhatsApp não encontradas');

View file

@ -6,6 +6,12 @@ import { useEmpresa } from "@/contexts/EmpresaContext";
import { translateError } from "@/lib/error-translator"; import { translateError } from "@/lib/error-translator";
import Image from "next/image"; import Image from "next/image";
const BFF_BASE_URL =
process.env.NEXT_PUBLIC_BFF_API_URL ||
(process.env.NEXT_PUBLIC_API_URL
? `${process.env.NEXT_PUBLIC_API_URL}/api/v1`
: "https://api-dev.saveinmed.com.br/api/v1");
/** /**
* Componente interno que usa useSearchParams * Componente interno que usa useSearchParams
*/ */
@ -54,7 +60,7 @@ const LoginPageContent = () => {
// Verificar autenticação usando BFF com o token no header Authorization // Verificar autenticação usando BFF com o token no header Authorization
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_BFF_API_URL}/auth/me`, `${BFF_BASE_URL}/auth/me`,
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -92,7 +98,7 @@ const LoginPageContent = () => {
try { try {
// 1. Fazer login no BFF // 1. Fazer login no BFF
const baseUrl = process.env.NEXT_PUBLIC_BFF_API_URL!; const baseUrl = BFF_BASE_URL;
const response = await fetch(`${baseUrl}/auth/login`, { const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -228,7 +234,7 @@ const LoginPageContent = () => {
try { try {
// 1. Fazer registro no BFF com dados corretos // 1. Fazer registro no BFF com dados corretos
const baseUrl = process.env.NEXT_PUBLIC_BFF_API_URL!; const baseUrl = BFF_BASE_URL;
const response = await fetch(`${baseUrl}/auth/register`, { const response = await fetch(`${baseUrl}/auth/register`, {
method: 'POST', method: 'POST',
headers: { headers: {

View file

@ -42,7 +42,7 @@ export class EmailService {
* Envia notificação por email para administrador sobre novo cadastro * Envia notificação por email para administrador sobre novo cadastro
*/ */
async sendAdminNotification(userData: EmailData): Promise<void> { async sendAdminNotification(userData: EmailData): Promise<void> {
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br'; const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
const mailOptions = { const mailOptions = {
@ -154,7 +154,7 @@ Notificação automática do sistema SaveInMed
* Envia email de boas-vindas para o usuário * Envia email de boas-vindas para o usuário
*/ */
async sendWelcomeEmail(userData: EmailData): Promise<void> { async sendWelcomeEmail(userData: EmailData): Promise<void> {
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
const mailOptions = { const mailOptions = {
from: `"SaveInMed - Boas Vindas" <${process.env.EMAIL_USER || 'nao-responda@saveinmed.com.br'}>`, from: `"SaveInMed - Boas Vindas" <${process.env.EMAIL_USER || 'nao-responda@saveinmed.com.br'}>`,

View file

@ -0,0 +1,48 @@
# Endpoint da API do Appwrite
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://seu-endpoint.appwrite.io/v1
# ID do projeto Appwrite
NEXT_PUBLIC_APPWRITE_PROJECT_ID=seu_projeto_id
# ID do banco de dados do Appwrite
NEXT_PUBLIC_APPWRITE_DATABASE_ID=seu_banco_de_dados_id
# URL Frontend
NEXT_PUBLIC_FRONTEND_URL=https://dev.saveinmed.com.br
# API principal SaveInMed
NEXT_PUBLIC_API_URL=https://api-dev.saveinmed.com.br
# API BFF SaveInMed
NEXT_PUBLIC_BFF_API_URL=https://bff-dev.saveinmed.com.br/api/v1
NEXT_PUBLIC_BFF_API_URL_MP=https://bff-dev.saveinmed.com.br
# IDs das coleções
NEXT_PUBLIC_APPWRITE_COLLECTION_ENDERECOS_ID=enderecos
NEXT_PUBLIC_APPWRITE_COLLECTION_LABORATORIOS_ID=laboratorios
NEXT_PUBLIC_APPWRITE_COLLECTION_EMPRESAS_ID=empresas
NEXT_PUBLIC_APPWRITE_COLLECTION_USUARIOS_ID=usuarios
# Chave de API do Appwrite (server-side)
APPWRITE_API_KEY=sua_chave_de_api_secreta
# Chave de API do Appwrite (client-side)
NEXT_PUBLIC_APPWRITE_API_KEY=sua_chave_de_api_publica
# === Resend API (alternativa moderna para emails) ===
# Criar conta gratuita em resend.com e obter API key
RESEND_API_KEY=re_xxxxxxxxx_xxxxxxxxxxxxxxxxxx
# Email autorizado para testes (deve ser o mesmo da conta Resend)
RESEND_TEST_EMAIL=email@exemplo.com
# === WhatsApp Evolution API ===
# Configurações para notificações via WhatsApp
EVOLUTION_API_URL=https://sua-evolution-api.com
EVOLUTION_API_KEY=sua_api_key
EVOLUTION_INSTANCE_NAME=sua_instancia
ADMIN_WHATSAPP=5511999999999
# === Mercado Pago (sandbox/test) ===
# Atenção: durante desenvolvimento use as credenciais de SANDBOX (começam com "TEST-")
# Substitua os placeholders abaixo pelos valores do seu ambiente de testes do Mercado Pago.
MERCADO_PAGO_ACCESS_TOKEN=APP_USR-seu_token_de_acesso
MERCADO_PAGO_PUBLIC_KEY=APP_USR-seu_public_key
NEXT_PUBLIC_MERCADO_PAGO_PUBLIC_KEY=APP_USR-seu_public_key
# URL pública usada por callbacks e redirecionamentos
NEXT_PUBLIC_APP_URL=https://dev.saveinmed.com.br
# Em produção, use tokens do tipo APP_USR-... e mantenha esses segredos apenas no servidor (não no client)

View file

@ -0,0 +1,372 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "react-hot-toast";
import Header from "@/components/Header";
import { useCarrinho } from "@/contexts/CarrinhoContext";
import { pedidoApiService } from "@/services/pedidoApiService";
import { useEmpresa } from "@/contexts/EmpresaContext";
import { CheckCircle, Truck, CreditCard, ChevronLeft, MapPin } from "lucide-react";
import PaymentBrick from "@/components/PaymentBrick";
export default function CheckoutPage() {
// ... (keep props)
const router = useRouter();
const searchParams = useSearchParams();
const pedidoId = searchParams.get("pedido");
const { itens, valorTotal, limparCarrinho } = useCarrinho();
const { empresa } = useEmpresa();
const [loading, setLoading] = useState(false);
const [step, setStep] = useState(1);
const [selectedAddressId, setSelectedAddressId] = useState<string | number | null>(null);
const [addresses, setAddresses] = useState<any[]>([]);
const [userProfile, setUserProfile] = useState<any>(null);
const [shippingOptions, setShippingOptions] = useState<any[]>([]);
const [selectedShippingOption, setSelectedShippingOption] = useState<any>(null);
const [shippingFee, setShippingFee] = useState(0);
const [shippingLoading, setShippingLoading] = useState(false);
const [paymentError, setPaymentError] = useState<string>('');
const [debugInfo, setDebugInfo] = useState<any>(null);
useEffect(() => {
// ... (keep useEffects)
// Fetch user profile and addresses
const fetchData = async () => {
try {
const token = pedidoApiService.getAuthToken();
if (!token) return;
// Fetch User
const meRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (meRes.ok) {
const userData = await meRes.json();
setUserProfile(userData);
}
// Fetch Addresses
const addrRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/enderecos`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (addrRes.ok) {
const addrData = await addrRes.json();
if (Array.isArray(addrData) && addrData.length > 0) {
setAddresses(addrData);
setSelectedAddressId(addrData[0].id);
} else if (!addrData || addrData.length === 0) {
const mock = {
id: 'mock-1',
logradouro: empresa?.endereco || "Rua Principal",
numero: empresa?.numero || "100",
bairro: empresa?.bairro || "Centro",
cidade: empresa?.cidade || "São Paulo",
uf: empresa?.estado || "SP",
cep: empresa?.cep || "01000-000",
titulo: "Endereço Padrão (Mock)"
};
setAddresses([mock]);
setSelectedAddressId('mock-1');
}
}
} catch (e) {
console.error("Error fetching checkout data", e);
}
};
fetchData();
}, [empresa]);
useEffect(() => {
if ((!itens || itens.length === 0) && !pedidoId) {
toast.error("Seu carrinho está vazio");
router.push("/produtos");
}
}, [itens, router, pedidoId]);
useEffect(() => {
const calculateShipping = async () => {
setShippingOptions([]);
setSelectedShippingOption(null);
setShippingFee(0);
setDebugInfo(null);
if ((empresa?.id || userProfile?.company_id || (itens[0]?.produto as any)?.seller_id) && selectedAddressId) {
const addr = addresses.find(a => a.id === selectedAddressId);
if (!addr) return;
const buyerLat = addr.latitude || -23.5505;
const buyerLon = addr.longitude || -46.6333;
const vendorId = empresa?.id || itens[0]?.produto?.empresa_id;
if (!vendorId) return;
setShippingLoading(true);
const payload = {
vendor_id: vendorId,
buyer_latitude: buyerLat,
buyer_longitude: buyerLon,
cart_total_cents: Math.round(valorTotal * 100)
};
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/shipping/calculate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
const options = await response.json();
if (Array.isArray(options) && options.length > 0) {
setShippingOptions(options);
const defaultOption = options[0];
setSelectedShippingOption(defaultOption);
setShippingFee(defaultOption.value_cents || 0);
}
}
} catch (e) {
console.error("Shipping calc error", e);
} finally {
setShippingLoading(false);
}
}
};
if (valorTotal > 0 && selectedAddressId) {
calculateShipping();
}
}, [valorTotal, empresa, selectedAddressId, addresses, userProfile, itens]);
const handleShippingChange = (option: any) => {
setSelectedShippingOption(option);
setShippingFee(option.value_cents || 0);
};
const handleBrickPayment = async (formData: any) => {
setLoading(true);
try {
if (!selectedAddressId) {
toast.error("Selecione um endereço de entrega");
setLoading(false);
return;
}
const addr = addresses.find(a => a.id === selectedAddressId);
const prod = itens[0]?.produto as any;
const vendorId = empresa?.id || prod?.seller_id || prod?.empresa_id || prod?.empresaId;
const payload = {
seller_id: vendorId,
items: itens.map(i => {
const p = i.produto as any;
return {
product_id: p.catalogo_id || p.product_id || p.id,
quantity: i.quantidade,
unit_cents: Math.round(((p.preco_final || p.price_cents / 100 || 0)) * 100)
};
}),
shipping: {
recipient_name: userProfile?.name || empresa?.razao_social || "Cliente",
street: addr.logradouro || addr.street || "Rua Principal",
number: addr.numero || addr.number || "123",
district: addr.bairro || addr.district || "Centro",
city: addr.cidade || addr.city || "São Paulo",
state: addr.uf || addr.state || "SP",
zip_code: addr.cep || addr.zip_code || "00000000",
latitude: addr.latitude || -23.5505,
longitude: addr.longitude || -46.6333,
country: "BR"
},
payment_method: {
type: "credit_card", // Generic placeholder
installments: formData.installments || 1
}
};
// 1. Create Order
const response = await pedidoApiService.criar(payload);
if (response && response.success && response.data) {
const orderId = response.data.id || response.data.$id;
// 2. Process Payment via Bricks Token
const payRes = await pedidoApiService.processarPagamento(orderId, formData);
if (payRes.success) {
setStep(3);
limparCarrinho();
toast.success("Pagamento aprovado!");
} else {
// Payment Failed
const errorMsg = payRes.error || "Pagamento não aprovado. Tente outro cartão.";
setPaymentError(errorMsg);
toast.error(errorMsg);
}
} else {
toast.error(response.error || "Erro ao criar pedido");
}
} catch (error) {
toast.error("Erro ao processar pedido");
console.error(error);
} finally {
setLoading(false);
}
};
if (step === 3) {
// (Confirmation Screen - kept same)
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<Header user={userProfile} />
<main className="flex-1 max-w-7xl mx-auto p-4 w-full flex items-center justify-center">
<div className="bg-white rounded-lg shadow p-8 text-center max-w-lg w-full">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Pedido Confirmado!</h2>
<p className="text-gray-600 mb-6">
Seu pedido foi processado com sucesso.
</p>
<div className="space-y-3">
<button onClick={() => router.push("/meus-pedidos")} className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition">Ver Meus Pedidos</button>
<button onClick={() => router.push("/produtos")} className="w-full bg-white border border-gray-300 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-50 transition">Voltar para Loja</button>
</div>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pb-12">
<Header user={userProfile} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<button onClick={() => router.back()} className="flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4">
<ChevronLeft className="w-4 h-4 mr-1" /> Voltar
</button>
<div className="flex items-center justify-center">
<div className={`flex items-center ${step >= 1 ? "text-blue-600" : "text-gray-400"}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 1 ? "border-blue-600 bg-blue-50" : "border-gray-300"} font-bold mr-2`}>1</div>
<span className="font-medium">Resumo & Entrega</span>
</div>
<div className={`w-12 h-0.5 mx-4 ${step >= 2 ? "bg-blue-600" : "bg-gray-300"}`}></div>
<div className={`flex items-center ${step >= 2 ? "text-blue-600" : "text-gray-400"}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 2 ? "border-blue-600 bg-blue-50" : "border-gray-300"} font-bold mr-2`}>2</div>
<span className="font-medium">Pagamento</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5 text-gray-500" /> Endereço de Entrega
</h3>
{/* Address List - Simplified for brevety in replace, but ideally keep logic */}
<div className="grid gap-4">
{addresses.map((addr) => (
<div
key={addr.id}
className={`border rounded-lg p-4 cursor-pointer relative transition-all ${selectedAddressId === addr.id ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300'}`}
onClick={() => setSelectedAddressId(addr.id)}
>
<p className="font-medium text-gray-900">{addr.titulo || userProfile?.name}</p>
<p className="text-sm text-gray-600">{addr.logradouro || addr.street}, {addr.numero || addr.number}</p>
{selectedAddressId === addr.id && <CheckCircle className="w-5 h-5 text-blue-600 absolute top-4 right-4" />}
</div>
))}
</div>
</div>
{selectedAddressId && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<Truck className="w-5 h-5 text-gray-700" /> Método de Entrega
</h3>
{shippingLoading && <div className="text-gray-700">Calculando frete...</div>}
{!shippingLoading && shippingOptions.map((option, idx) => (
<label key={idx} className={`flex items-center p-4 border rounded-lg cursor-pointer mt-2 ${selectedShippingOption?.type === option.type ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="shipping" value={option.type} checked={selectedShippingOption?.type === option.type} onChange={() => handleShippingChange(option)} className="h-4 w-4 text-blue-600" />
<div className="ml-4 flex-1 flex justify-between">
<span className="text-black font-semibold">{option.description}</span>
<span className="text-black font-bold">{option.value_cents === 0 ? "Grátis" : `R$ ${(option.value_cents / 100).toFixed(2)}`}</span>
</div>
</label>
))}
</div>
)}
{step === 2 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-fade-in">
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-gray-700" /> Pagamento
</h3>
{paymentError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-2">
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
<span>{paymentError}</span>
</div>
)}
<PaymentBrick
amount={(valorTotal * 100 + shippingFee) / 100}
onPayment={handleBrickPayment}
/>
</div>
)}
</div>
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sticky top-6">
<h3 className="text-lg font-semibold text-black mb-4">Resumo do Pedido</h3>
<div className="space-y-4 mb-6 max-h-60 overflow-y-auto">
{itens.map((item) => (
<div key={item.produto.id} className="flex justify-between text-sm">
<span className="text-gray-700 flex-1 truncate mr-2">{item.quantidade}x {item.produto.nome}</span>
<span className="font-bold text-black">R$ {((item.produto.preco_final || 0) * item.quantidade).toFixed(2)}</span>
</div>
))}
</div>
<div className="border-t border-gray-200 pt-4 space-y-2">
<div className="flex justify-between text-sm"><span className="text-gray-700 font-medium">Subtotal</span><span className="text-black font-bold">R$ {valorTotal.toFixed(2)}</span></div>
<div className="flex justify-between text-sm"><span className="text-gray-700 font-medium">Frete</span><span className="text-black font-bold">R$ {(shippingFee / 100).toFixed(2)}</span></div>
<div className="flex justify-between text-lg font-bold pt-2 border-t border-gray-100 mt-2">
<span className="text-black">Total</span><span className="text-blue-700">R$ {((valorTotal * 100 + shippingFee) / 100).toFixed(2)}</span>
</div>
</div>
<div className="mt-6">
{step === 1 ? (
<button onClick={() => setStep(2)} disabled={!selectedAddressId} className={`w-full text-white py-3 rounded-lg font-bold transition ${!selectedAddressId ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
Continuar para Pagamento
</button>
) : (
<div className="space-y-3">
<button onClick={() => setStep(1)} disabled={loading} className="w-full bg-white border border-gray-300 text-gray-700 py-2 rounded-lg font-medium hover:bg-gray-50 transition">
Voltar
</button>
</div>
)}
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,95 @@
import axios from 'axios';
import { usuarioApiService } from './usuarioApiService';
const API_URL = process.env.NEXT_PUBLIC_BFF_API_URL || 'https://bff-dev.saveinmed.com.br/api/v1';
export interface EnderecoInput {
titulo: string;
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
uf: string;
entity_id?: string; // Optional override for admins
}
export interface Endereco {
id: string;
entity_id: string;
titulo: string;
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
uf: string;
latitude: number;
longitude: number;
created_at: string;
updated_at: string;
}
export const enderecoApiService = {
// Lists addresses. If userId provided and user is admin, implementation might vary,
// but currently list relies on logged in context.
// To list for another user, we might need to rely on the user object's 'enderecos' array
// or add a query param if backend supports it.
// For now, we use this for the logged-in actions or if we update the backend List to take params.
listar: async (entityId?: string): Promise<Endereco[]> => {
try {
const response = await axios.get(`${API_URL}/enderecos`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
params: entityId ? { entity_id: entityId } : {}
});
return response.data;
} catch (error) {
console.error('Erro ao listar endereços', error);
throw error;
}
},
criar: async (dados: EnderecoInput): Promise<Endereco> => {
try {
const response = await axios.post(`${API_URL}/enderecos`, dados, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
return response.data;
} catch (error) {
console.error('Erro ao criar endereço', error);
throw error;
}
},
atualizar: async (id: string, dados: EnderecoInput): Promise<void> => {
try {
await axios.put(`${API_URL}/enderecos/${id}`, dados, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
} catch (error) {
console.error('Erro ao atualizar endereço', error);
throw error;
}
},
apagar: async (id: string): Promise<void> => {
try {
await axios.delete(`${API_URL}/enderecos/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
} catch (error) {
console.error('Erro ao apagar endereço', error);
throw error;
}
}
};