fix: correcoes de acesso e marketplace

- Corrige algoritmo de validacao CNPJ (pesos completos 12/13 digitos)
- Auto-login apos cadastro de usuario redirecionando para /seller
- Registro: role padrao Seller quando campo vazio, mapeamento company_name/cnpj
- Adiciona role Seller ao middleware productManagers (fix 403 em criacao de produto)
- Inventario: usa campos corretos da API (nome, ean_code, sale_price_cents, stock_quantity)
- Marketplace: raio padrao nacional (5000km), empresas sem coordenadas sempre visiveis
- dto.go: adiciona CompanyName e CNPJ ao registerAuthRequest
This commit is contained in:
joaoaodt 2026-02-27 14:44:30 -03:00
parent c1c1dd83a5
commit eb9dc8be1d
11 changed files with 108 additions and 48 deletions

View file

@ -31,7 +31,7 @@ func main() {
// Nova senha: senha123 // Nova senha: senha123
newPassword := "senha123" newPassword := "senha123"
// Hash da senha com bcrypt // Hash da senha com bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword+cfg.PasswordPepper), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword+cfg.PasswordPepper), bcrypt.DefaultCost)
if err != nil { if err != nil {

View file

@ -29,13 +29,15 @@ type createUserRequest struct {
} }
type registerAuthRequest struct { type registerAuthRequest struct {
CompanyID *uuid.UUID `json:"company_id,omitempty"` CompanyID *uuid.UUID `json:"company_id,omitempty"`
Company *registerCompanyTarget `json:"company,omitempty"` Company *registerCompanyTarget `json:"company,omitempty"`
Role string `json:"role"` Role string `json:"role"`
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
CompanyName string `json:"company_name,omitempty"` // Frontend sends this
CNPJ string `json:"cnpj,omitempty"` // Frontend sends this
} }
type registerCompanyTarget struct { type registerCompanyTarget struct {

View file

@ -60,6 +60,19 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
} }
} }
// If company_name and cnpj are sent directly (from frontend), create company object
if company == nil && req.CompanyName != "" && req.CNPJ != "" {
company = &domain.Company{
Category: "farmacia",
CNPJ: req.CNPJ,
CorporateName: req.CompanyName,
LicenseNumber: "PENDING",
IsVerified: false,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
}
var companyID uuid.UUID var companyID uuid.UUID
if req.CompanyID != nil { if req.CompanyID != nil {
companyID = *req.CompanyID companyID = *req.CompanyID
@ -73,7 +86,12 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
Email: req.Email, Email: req.Email,
} }
// If no company provided, create a placeholder one to satisfy DB constraints // Default role to Seller if not provided
if user.Role == "" {
user.Role = "Seller"
}
// If no company provided at all, create a placeholder one to satisfy DB constraints
if user.CompanyID == uuid.Nil && company == nil { if user.CompanyID == uuid.Nil && company == nil {
timestamp := time.Now().UnixNano() timestamp := time.Now().UnixNano()
company = &domain.Company{ company = &domain.Company{

View file

@ -2,6 +2,7 @@ package handler
import ( import (
"errors" "errors"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -33,9 +34,13 @@ func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
return return
} }
// Debug logging
log.Printf("🔍 [CreateProduct] Role: %s, CompanyID: %v", claims.Role, claims.CompanyID)
// If not Admin/Superadmin, force SellerID to be their CompanyID // If not Admin/Superadmin, force SellerID to be their CompanyID
if claims.Role != "Admin" && claims.Role != "superadmin" { if claims.Role != "Admin" && claims.Role != "superadmin" {
if claims.CompanyID == nil { if claims.CompanyID == nil {
log.Printf("❌ [CreateProduct] CompanyID is nil for user %s with role %s", claims.UserID, claims.Role)
writeError(w, http.StatusForbidden, errors.New("user has no company")) writeError(w, http.StatusForbidden, errors.New("user has no company"))
return return
} }

View file

@ -3,6 +3,7 @@ package middleware
import ( import (
"context" "context"
"errors" "errors"
"log"
"net/http" "net/http"
"strings" "strings"
@ -27,15 +28,21 @@ func RequireAuth(secret []byte, allowedRoles ...string) func(http.Handler) http.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := parseToken(r, secret) claims, err := parseToken(r, secret)
if err != nil { if err != nil {
log.Printf("❌ [RequireAuth] Token parse error: %v", err)
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
return return
} }
log.Printf("🔍 [RequireAuth] User Role: %s, Allowed Roles: %v", claims.Role, allowedRoles)
if len(allowedRoles) > 0 && !isRoleAllowed(claims.Role, allowedRoles) { if len(allowedRoles) > 0 && !isRoleAllowed(claims.Role, allowedRoles) {
log.Printf("❌ [RequireAuth] Role %s not in allowed roles %v", claims.Role, allowedRoles)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return return
} }
log.Printf("✅ [RequireAuth] Access granted for role: %s", claims.Role)
ctx := context.WithValue(r.Context(), claimsKey, *claims) ctx := context.WithValue(r.Context(), claimsKey, *claims)
ctx = context.WithValue(ctx, "company_id", claims.CompanyID) ctx = context.WithValue(ctx, "company_id", claims.CompanyID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))

View file

@ -393,11 +393,16 @@ func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSe
// Calculate distance and build response // Calculate distance and build response
results := make([]domain.ProductWithDistance, 0, len(rows)) results := make([]domain.ProductWithDistance, 0, len(rows))
for _, row := range rows { for _, row := range rows {
dist := domain.HaversineDistance(filter.BuyerLat, filter.BuyerLng, row.Latitude, row.Longitude) var dist float64
// If company has no location set (0,0), always include and show distance 0
// Filter by max distance if specified if row.Latitude == 0 && row.Longitude == 0 {
if filter.MaxDistanceKm != nil && dist > *filter.MaxDistanceKm { dist = 0
continue } else {
dist = domain.HaversineDistance(filter.BuyerLat, filter.BuyerLng, row.Latitude, row.Longitude)
// Filter by max distance only for companies with valid coordinates
if filter.MaxDistanceKm != nil && dist > *filter.MaxDistanceKm {
continue
}
} }
results = append(results, domain.ProductWithDistance{ results = append(results, domain.ProductWithDistance{

View file

@ -66,8 +66,8 @@ 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), "Admin") // Keep for strict admin routes if any
// Allow Admin, Superadmin, Dono, Gerente to manage products // Allow Admin, Superadmin, Dono, Gerente, and Seller to manage products
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente") productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller")
// Companies (Empresas) // Companies (Empresas)
mux.Handle("POST /api/v1/companies", chain(http.HandlerFunc(h.CreateCompany), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/companies", chain(http.HandlerFunc(h.CreateCompany), middleware.Logger, middleware.Gzip))

View file

@ -1,8 +1,10 @@
import { FormEvent, useState } from 'react' import { FormEvent, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { useAuth, UserRole } from '@/context/AuthContext'
import { authService } from '@/services/auth' import { authService } from '@/services/auth'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj' import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import { decodeJwtPayload } from '@/utils/jwt'
import logoImg from '@/assets/logo.png' import logoImg from '@/assets/logo.png'
interface RegistrationFormData { interface RegistrationFormData {
@ -17,6 +19,7 @@ interface RegistrationFormData {
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth()
const [formData, setFormData] = useState<RegistrationFormData>({ const [formData, setFormData] = useState<RegistrationFormData>({
email: '', email: '',
username: '', username: '',
@ -87,6 +90,17 @@ export function RegisterPage() {
return Object.keys(errors).length === 0 return Object.keys(errors).length === 0
} }
const resolveRole = (role?: string): UserRole => {
switch (role?.toLowerCase()) {
case 'admin': return 'admin'
case 'dono': return 'owner'
case 'colaborador': return 'employee'
case 'entregador': return 'delivery'
case 'customer': return 'customer'
case 'seller': default: return 'seller'
}
}
const onSubmit = async (event: FormEvent) => { const onSubmit = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@ -99,7 +113,7 @@ export function RegisterPage() {
setErrorMessage(null) setErrorMessage(null)
try { try {
await authService.register({ const response = await authService.register({
email: formData.email, email: formData.email,
username: formData.username, username: formData.username,
password: formData.password, password: formData.password,
@ -108,8 +122,15 @@ export function RegisterPage() {
cnpj: formData.cnpj.replace(/\D/g, ''), cnpj: formData.cnpj.replace(/\D/g, ''),
}) })
// Show success and redirect to login // Auto login after successful registration
navigate('/login?registered=true') const { token } = response
if (!token) throw new Error('Resposta de registro inválida.')
const payload = decodeJwtPayload<{ role?: string, sub: string, company_id?: string }>(token)
const role = resolveRole(payload?.role)
// Login automatically and redirect to dashboard
login(token, role, formData.name, payload?.sub || '', payload?.company_id, formData.email, formData.username)
} catch (error) { } catch (error) {
const fallback = 'Não foi possível criar a conta. Tente novamente.' const fallback = 'Não foi possível criar a conta. Tente novamente.'
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {

View file

@ -8,11 +8,12 @@ import { formatCents } from '@/utils/format'
interface InventoryItem { interface InventoryItem {
product_id: string product_id: string
seller_id: string seller_id: string
name: string nome: string
ean_code: string
batch: string batch: string
expires_at: string expires_at: string
quantity: number stock_quantity: number
price_cents: number sale_price_cents: number
} }
export function InventoryPage() { export function InventoryPage() {
@ -153,27 +154,27 @@ export function InventoryPage() {
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}> <thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Produto</th> <th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Nome</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Lote</th> <th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">EAN</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Validade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Quantidade</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Preço</th> <th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Preço</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase text-white">Estoque</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase text-white">Validade</th>
<th className="px-4 py-3 text-center text-xs font-semibold uppercase text-white">Ações</th> <th className="px-4 py-3 text-center text-xs font-semibold uppercase text-white">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{inventory.map((item) => ( {inventory.map((item) => (
<tr key={`${item.product_id}-${item.batch}`} className={isExpiringSoon(item.expires_at) ? 'bg-yellow-50' : ''}> <tr key={`${item.product_id}-${item.batch}`} className={isExpiringSoon(item.expires_at) ? 'bg-yellow-50' : ''}>
<td className="px-4 py-3 text-sm font-medium text-gray-800">{item.name}</td> <td className="px-4 py-3 text-sm font-medium text-gray-800">{item.nome || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.batch}</td> <td className="px-4 py-3 text-sm text-gray-600">{item.ean_code || '—'}</td>
<td className="px-4 py-3 text-right text-sm text-medicalBlue">
{formatCents(item.sale_price_cents)}
</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-800">{item.stock_quantity}</td>
<td className={`px-4 py-3 text-sm ${isExpiringSoon(item.expires_at) ? 'font-semibold text-orange-600' : 'text-gray-600'}`}> <td className={`px-4 py-3 text-sm ${isExpiringSoon(item.expires_at) ? 'font-semibold text-orange-600' : 'text-gray-600'}`}>
{new Date(item.expires_at).toLocaleDateString('pt-BR')} {new Date(item.expires_at).toLocaleDateString('pt-BR')}
{isExpiringSoon(item.expires_at) && <span className="ml-2 text-xs"></span>} {isExpiringSoon(item.expires_at) && <span className="ml-2 text-xs"></span>}
</td> </td>
<td className="px-4 py-3 text-right text-sm font-semibold text-gray-800">{item.quantity}</td>
<td className="px-4 py-3 text-right text-sm text-medicalBlue">
{formatCents(item.price_cents)}
</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
<button <button

View file

@ -12,7 +12,7 @@ const ProductSearch = () => {
const [lat, setLat] = useState<number>(-16.3285) const [lat, setLat] = useState<number>(-16.3285)
const [lng, setLng] = useState<number>(-48.9534) const [lng, setLng] = useState<number>(-48.9534)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [maxDistance, setMaxDistance] = useState<number>(10) const [maxDistance, setMaxDistance] = useState<number>(5000)
const [products, setProducts] = useState<ProductWithDistance[]>([]) const [products, setProducts] = useState<ProductWithDistance[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@ -134,7 +134,7 @@ const ProductSearch = () => {
setMinPrice('') setMinPrice('')
setMaxPrice('') setMaxPrice('')
setMinExpiryDays(0) setMinExpiryDays(0)
setMaxDistance(10) setMaxDistance(5000)
} }
return ( return (
@ -160,12 +160,13 @@ const ProductSearch = () => {
<div> <div>
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
Raio de busca: <span className="text-primary font-bold">{maxDistance} km</span> Raio de busca: <span className="text-primary font-bold">{maxDistance >= 5000 ? 'Nacional' : `${maxDistance} km`}</span>
</label> </label>
<input <input
type="range" type="range"
min="1" min="1"
max="50" max="5000"
step="10"
value={maxDistance} value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))} onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full mt-2" className="w-full mt-2"

View file

@ -16,31 +16,31 @@ export function validateCNPJ(cnpj: string): boolean {
return false return false
} }
// Validate first check digit // Validate first check digit (position 12)
// Weights: 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
let sum = 0 let sum = 0
let position = 5 for (let i = 0; i < 12; i++) {
for (let i = 0; i < 8; i++) { sum += parseInt(cleaned[i]) * weights1[i]
sum += parseInt(cleaned[i]) * position
position -= 1
} }
let remainder = sum % 11 let remainder = sum % 11
const firstDigit = remainder < 2 ? 0 : 11 - remainder const firstDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[8]) !== firstDigit) { if (parseInt(cleaned[12]) !== firstDigit) {
return false return false
} }
// Validate second check digit // Validate second check digit (position 13)
// Weights: 6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
sum = 0 sum = 0
position = 9 for (let i = 0; i < 13; i++) {
for (let i = 0; i < 9; i++) { sum += parseInt(cleaned[i]) * weights2[i]
sum += parseInt(cleaned[i]) * position
position -= 1
} }
remainder = sum % 11 remainder = sum % 11
const secondDigit = remainder < 2 ? 0 : 11 - remainder const secondDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[9]) !== secondDigit) { if (parseInt(cleaned[13]) !== secondDigit) {
return false return false
} }