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

@ -29,13 +29,15 @@ type createUserRequest struct {
}
type registerAuthRequest struct {
CompanyID *uuid.UUID `json:"company_id,omitempty"`
Company *registerCompanyTarget `json:"company,omitempty"`
Role string `json:"role"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CompanyID *uuid.UUID `json:"company_id,omitempty"`
Company *registerCompanyTarget `json:"company,omitempty"`
Role string `json:"role"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CompanyName string `json:"company_name,omitempty"` // Frontend sends this
CNPJ string `json:"cnpj,omitempty"` // Frontend sends this
}
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
if req.CompanyID != nil {
companyID = *req.CompanyID
@ -73,7 +86,12 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
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 {
timestamp := time.Now().UnixNano()
company = &domain.Company{

View file

@ -2,6 +2,7 @@ package handler
import (
"errors"
"log"
"net/http"
"strconv"
"time"
@ -33,9 +34,13 @@ func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
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 claims.Role != "Admin" && claims.Role != "superadmin" {
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"))
return
}

View file

@ -3,6 +3,7 @@ package middleware
import (
"context"
"errors"
"log"
"net/http"
"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) {
claims, err := parseToken(r, secret)
if err != nil {
log.Printf("❌ [RequireAuth] Token parse error: %v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
log.Printf("🔍 [RequireAuth] User Role: %s, Allowed Roles: %v", 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)
return
}
log.Printf("✅ [RequireAuth] Access granted for role: %s", claims.Role)
ctx := context.WithValue(r.Context(), claimsKey, *claims)
ctx = context.WithValue(ctx, "company_id", claims.CompanyID)
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
results := make([]domain.ProductWithDistance, 0, len(rows))
for _, row := range rows {
dist := domain.HaversineDistance(filter.BuyerLat, filter.BuyerLng, row.Latitude, row.Longitude)
// Filter by max distance if specified
if filter.MaxDistanceKm != nil && dist > *filter.MaxDistanceKm {
continue
var dist float64
// If company has no location set (0,0), always include and show distance 0
if row.Latitude == 0 && row.Longitude == 0 {
dist = 0
} 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{

View file

@ -66,8 +66,8 @@ func New(cfg config.Config) (*Server, error) {
auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin") // Keep for strict admin routes if any
// Allow Admin, Superadmin, Dono, Gerente to manage products
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente")
// Allow Admin, Superadmin, Dono, Gerente, and Seller to manage products
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller")
// Companies (Empresas)
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 { useNavigate, Link } from 'react-router-dom'
import axios from 'axios'
import { useAuth, UserRole } from '@/context/AuthContext'
import { authService } from '@/services/auth'
import { formatCNPJ, validateCNPJ } from '@/utils/cnpj'
import { decodeJwtPayload } from '@/utils/jwt'
import logoImg from '@/assets/logo.png'
interface RegistrationFormData {
@ -17,6 +19,7 @@ interface RegistrationFormData {
export function RegisterPage() {
const navigate = useNavigate()
const { login } = useAuth()
const [formData, setFormData] = useState<RegistrationFormData>({
email: '',
username: '',
@ -87,6 +90,17 @@ export function RegisterPage() {
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) => {
event.preventDefault()
@ -99,7 +113,7 @@ export function RegisterPage() {
setErrorMessage(null)
try {
await authService.register({
const response = await authService.register({
email: formData.email,
username: formData.username,
password: formData.password,
@ -108,8 +122,15 @@ export function RegisterPage() {
cnpj: formData.cnpj.replace(/\D/g, ''),
})
// Show success and redirect to login
navigate('/login?registered=true')
// Auto login after successful registration
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) {
const fallback = 'Não foi possível criar a conta. Tente novamente.'
if (axios.isAxiosError(error)) {

View file

@ -8,11 +8,12 @@ import { formatCents } from '@/utils/format'
interface InventoryItem {
product_id: string
seller_id: string
name: string
nome: string
ean_code: string
batch: string
expires_at: string
quantity: number
price_cents: number
stock_quantity: number
sale_price_cents: number
}
export function InventoryPage() {
@ -153,27 +154,27 @@ export function InventoryPage() {
<table className="min-w-full divide-y divide-gray-200">
<thead className="text-white" style={{ backgroundColor: '#0F4C81' }}>
<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">Lote</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-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">EAN</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>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{inventory.map((item) => (
<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 text-gray-600">{item.batch}</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.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'}`}>
{new Date(item.expires_at).toLocaleDateString('pt-BR')}
{isExpiringSoon(item.expires_at) && <span className="ml-2 text-xs"></span>}
</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">
<div className="flex justify-center gap-2">
<button

View file

@ -12,7 +12,7 @@ const ProductSearch = () => {
const [lat, setLat] = useState<number>(-16.3285)
const [lng, setLng] = useState<number>(-48.9534)
const [search, setSearch] = useState('')
const [maxDistance, setMaxDistance] = useState<number>(10)
const [maxDistance, setMaxDistance] = useState<number>(5000)
const [products, setProducts] = useState<ProductWithDistance[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
@ -134,7 +134,7 @@ const ProductSearch = () => {
setMinPrice('')
setMaxPrice('')
setMinExpiryDays(0)
setMaxDistance(10)
setMaxDistance(5000)
}
return (
@ -160,12 +160,13 @@ const ProductSearch = () => {
<div>
<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>
<input
type="range"
min="1"
max="50"
max="5000"
step="10"
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
className="w-full mt-2"

View file

@ -16,31 +16,31 @@ export function validateCNPJ(cnpj: string): boolean {
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 position = 5
for (let i = 0; i < 8; i++) {
sum += parseInt(cleaned[i]) * position
position -= 1
for (let i = 0; i < 12; i++) {
sum += parseInt(cleaned[i]) * weights1[i]
}
let remainder = sum % 11
const firstDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[8]) !== firstDigit) {
if (parseInt(cleaned[12]) !== firstDigit) {
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
position = 9
for (let i = 0; i < 9; i++) {
sum += parseInt(cleaned[i]) * position
position -= 1
for (let i = 0; i < 13; i++) {
sum += parseInt(cleaned[i]) * weights2[i]
}
remainder = sum % 11
const secondDigit = remainder < 2 ? 0 : 11 - remainder
if (parseInt(cleaned[9]) !== secondDigit) {
if (parseInt(cleaned[13]) !== secondDigit) {
return false
}