photum/backend/internal/profissionais/handler.go
NANDO9322 a4982e588e feat(profile): melhorias no fluxo de perfil e correções no backend
Frontend:
- Implementado fluxo de inicialização para novos perfis (modal "Complete seu Cadastro").
- Adicionada lógica para pré-preencher nome e email do usuário no cadastro.
- Adicionada renderização condicional: abas "Dados Bancários" e "Profissional" são ocultadas para clientes (EVENT_OWNER).
- Unificada a função de salvar (criação e edição) com tratativa correta de erros e feedback (Toast).
- Adicionado fallback para exibir o email do usuário caso o do perfil esteja vazio.

Backend:
- SQL: Ajustada query `GetProfissionalByUsuarioID` para buscar email da tabela de usuários (LEFT JOIN).
- Handler: Implementado fallback para usar `UsuarioEmail` na resposta se o `Email` do perfil for nulo.
- Service: Correção no salvamento (Create/Update) para tratar `funcao_profissional_id` com UUID vazio (Nil) como NULL, evitando erro de chave estrangeira (FK).

Fixes #profile-save-error, #role-visibility
2026-02-06 21:44:00 -03:00

573 lines
19 KiB
Go

package profissionais
import (
"encoding/json"
"fmt"
"net/http"
"photum-backend/internal/db/generated"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// ProfissionalResponse struct for Swagger and JSON response
type ProfissionalResponse struct {
ID string `json:"id"`
UsuarioID string `json:"usuario_id"`
Nome string `json:"nome"`
FuncaoProfissional string `json:"funcao_profissional"` // Deprecated single name (optional)
FuncaoProfissionalID string `json:"funcao_profissional_id"`
Functions json.RawMessage `json:"functions"` // JSON array
Endereco *string `json:"endereco"`
Cidade *string `json:"cidade"`
Uf *string `json:"uf"`
Whatsapp *string `json:"whatsapp"`
CpfCnpjTitular *string `json:"cpf_cnpj_titular"`
Banco *string `json:"banco"`
Agencia *string `json:"agencia"`
Conta *string `json:"conta"`
ContaPix *string `json:"conta_pix"`
CarroDisponivel *bool `json:"carro_disponivel"`
TemEstudio *bool `json:"tem_estudio"`
QtdEstudio *int `json:"qtd_estudio"`
TipoCartao *string `json:"tipo_cartao"`
Observacao *string `json:"observacao"`
QualTec *int `json:"qual_tec"`
EducacaoSimpatia *int `json:"educacao_simpatia"`
DesempenhoEvento *int `json:"desempenho_evento"`
DispHorario *int `json:"disp_horario"`
Media *float64 `json:"media"`
TabelaFree *string `json:"tabela_free"`
ExtraPorEquipamento *bool `json:"extra_por_equipamento"`
Equipamentos *string `json:"equipamentos"`
Email *string `json:"email"`
AvatarURL *string `json:"avatar_url"`
}
func toResponse(p interface{}) ProfissionalResponse {
// Handle different types returned by queries (Create returns CadastroProfissionai, List/Get returns Row with join)
// This is a bit hacky, ideally we'd have a unified model or separate response mappers.
// For now, let's check type.
switch v := p.(type) {
case generated.CadastroProfissionai:
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
// FuncaoProfissional name is not available in simple insert return without extra query or join
FuncaoProfissional: "",
Functions: json.RawMessage("[]"), // Empty on Create (or Fetch specifically if needed)
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
Conta: fromPgText(v.Conta),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: fromPgText(v.Email),
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.ListProfissionaisRow:
email := fromPgText(v.Email)
if email == nil {
email = fromPgText(v.UsuarioEmail)
}
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "", // v.FuncaoNome removed from query or changed?
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
Conta: fromPgText(v.Conta),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: email,
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.GetProfissionalByIDRow:
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "", // v.FuncaoNome removed
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
Conta: fromPgText(v.Conta),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: fromPgText(v.Email),
AvatarURL: fromPgText(v.AvatarUrl),
}
case generated.GetProfissionalByUsuarioIDRow:
email := fromPgText(v.Email)
if email == nil {
email = fromPgText(v.UsuarioEmail)
}
return ProfissionalResponse{
ID: uuid.UUID(v.ID.Bytes).String(),
UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(),
Nome: v.Nome,
FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(),
FuncaoProfissional: "",
Functions: toJSONRaw(v.Functions),
Endereco: fromPgText(v.Endereco),
Cidade: fromPgText(v.Cidade),
Uf: fromPgText(v.Uf),
Whatsapp: fromPgText(v.Whatsapp),
CpfCnpjTitular: fromPgText(v.CpfCnpjTitular),
Banco: fromPgText(v.Banco),
Agencia: fromPgText(v.Agencia),
Conta: fromPgText(v.Conta),
ContaPix: fromPgText(v.ContaPix),
CarroDisponivel: fromPgBool(v.CarroDisponivel),
TemEstudio: fromPgBool(v.TemEstudio),
QtdEstudio: fromPgInt4(v.QtdEstudio),
TipoCartao: fromPgText(v.TipoCartao),
Observacao: fromPgText(v.Observacao),
QualTec: fromPgInt4(v.QualTec),
EducacaoSimpatia: fromPgInt4(v.EducacaoSimpatia),
DesempenhoEvento: fromPgInt4(v.DesempenhoEvento),
DispHorario: fromPgInt4(v.DispHorario),
Media: fromPgNumeric(v.Media),
TabelaFree: fromPgText(v.TabelaFree),
ExtraPorEquipamento: fromPgBool(v.ExtraPorEquipamento),
Equipamentos: fromPgText(v.Equipamentos),
Email: email,
AvatarURL: fromPgText(v.AvatarUrl),
}
default:
return ProfissionalResponse{}
}
}
// Helpers for conversion
func fromPgText(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func fromPgBool(b pgtype.Bool) *bool {
if !b.Valid {
return nil
}
return &b.Bool
}
func fromPgInt4(i pgtype.Int4) *int {
if !i.Valid {
return nil
}
val := int(i.Int32)
return &val
}
func fromPgNumeric(n pgtype.Numeric) *float64 {
if !n.Valid {
return nil
}
f, _ := n.Float64Value()
val := f.Float64
return &val
}
func toJSONRaw(v interface{}) json.RawMessage {
if v == nil {
return json.RawMessage("[]")
}
switch val := v.(type) {
case []byte:
if len(val) == 0 {
return json.RawMessage("[]")
}
return json.RawMessage(val)
case string:
if val == "" {
return json.RawMessage("[]")
}
return json.RawMessage([]byte(val))
default:
// Fallback: marshal strictly? or return empty array
b, _ := json.Marshal(v)
return json.RawMessage(b)
}
}
// Create godoc
// @Summary Create a new profissional
// @Description Create a new profissional record
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateProfissionalInput true "Create Profissional Request"
// @Success 201 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais [post]
func (h *Handler) Create(c *gin.Context) {
var input CreateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fmt.Printf("[DEBUG] Create Input: %+v\n", input)
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
userIDStr, ok := userID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id type in context"})
return
}
// Security: Only allow TargetUserID if user is ADMIN or OWNER
// Also handle Region override
userRole, _ := c.Get("role")
roleStr, _ := userRole.(string)
if input.TargetUserID != nil && *input.TargetUserID != "" {
if roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER" {
input.TargetUserID = nil
}
}
regiao := c.GetString("regiao")
// If input has regiao and user is admin, use it
if input.Regiao != nil && *input.Regiao != "" {
if roleStr == "SUPERADMIN" || roleStr == "BUSINESS_OWNER" {
regiao = *input.Regiao
}
}
prof, err := h.service.Create(c.Request.Context(), userIDStr, input, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, toResponse(*prof))
}
// List godoc
// @Summary List profissionais
// @Description List all profissionais
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} ProfissionalResponse
// @Failure 500 {object} map[string]string
// @Router /api/profissionais [get]
func (h *Handler) List(c *gin.Context) {
regiao := c.GetString("regiao")
profs, err := h.service.List(c.Request.Context(), regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var response []ProfissionalResponse
for _, p := range profs {
response = append(response, toResponse(p))
}
c.JSON(http.StatusOK, response)
}
// Me godoc
// @Summary Get current authenticated profissional
// @Description Get the profissional profile associated with the current user
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} ProfissionalResponse
// @Failure 401 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/me [get]
func (h *Handler) Me(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
userIDStr, ok := userID.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id type"})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.GetByUserID(c.Request.Context(), userIDStr, regiao)
if err != nil {
// Checks if error is "no rows in result set" -> 404
if err.Error() == "no rows in result set" {
c.JSON(http.StatusNotFound, gin.H{"error": "professional profile not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Reuse toResponse which handles different types via interface{} check
// We need to add case for GetProfissionalByUsuarioIDRow in toResponse function
c.JSON(http.StatusOK, toResponse(*prof))
}
// Get godoc
// @Summary Get profissional by ID
// @Description Get a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Success 200 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [get]
func (h *Handler) Get(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.GetByID(c.Request.Context(), id, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toResponse(*prof))
}
// Update godoc
// @Summary Update profissional
// @Description Update a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Param request body UpdateProfissionalInput true "Update Profissional Request"
// @Success 200 {object} ProfissionalResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [put]
func (h *Handler) Update(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
var input UpdateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
regiao := c.GetString("regiao")
prof, err := h.service.Update(c.Request.Context(), id, input, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, toResponse(*prof))
}
// Delete godoc
// @Summary Delete profissional
// @Description Delete a profissional by ID
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Profissional ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/{id} [delete]
func (h *Handler) Delete(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
regiao := c.GetString("regiao")
err := h.service.Delete(c.Request.Context(), id, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Import godoc
// @Summary Import professionals from properties
// @Description Import professionals (Upsert by CPF)
// @Tags profissionais
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body []CreateProfissionalInput true "List of Professionals"
// @Success 200 {object} ImportStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/profissionais/import [post]
func (h *Handler) Import(c *gin.Context) {
var input []CreateProfissionalInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Role check: Only ADMIN or OWNER
userRole, exists := c.Get("role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
roleStr := userRole.(string)
if roleStr != "SUPERADMIN" && roleStr != "BUSINESS_OWNER" {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
regiao := c.GetString("regiao")
stats, errs := h.service.Import(c.Request.Context(), input, regiao)
// Construct response with errors if any
response := gin.H{
"created": stats.Created,
"updated": stats.Updated,
"errors_count": stats.Errors,
}
if len(errs) > 0 {
var errMsgs []string
for _, e := range errs {
errMsgs = append(errMsgs, e.Error())
}
response["errors"] = errMsgs
}
c.JSON(http.StatusOK, response)
}
// CheckCPF godoc
// @Summary Check if professional exists by CPF
// @Description Check existence and claim status
// @Tags profissionais
// @Accept json
// @Produce json
// @Param cpf query string true "CPF/CNPJ"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /api/public/profissionais/check [get]
func (h *Handler) CheckCPF(c *gin.Context) {
cpf := c.Query("cpf")
if cpf == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "cpf required"})
return
}
// Clean CPF (optional, but good practice)
// For now rely on exact match or client cleaning.
// Ideally service should clean.
// Check existence
regiao := c.GetString("regiao")
exists, claimed, name, err := h.service.CheckExistence(c.Request.Context(), cpf, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return limited info for public check
c.JSON(http.StatusOK, gin.H{
"exists": exists,
"claimed": claimed,
"name": name, // Returning name might be PII, but usually acceptable for confirmation "Is this you?"
})
}